Inspecting Preflight in the DevTools Network Panel

The browser Network panel is the fastest place to confirm whether a cross-origin request triggered a CORS preflight and, if so, exactly which header the server refused. Yet the panel also hides the one thing you most want to see: when a request is blocked, DevTools frequently shows only “provisional headers” instead of the real response, sending developers on a hunt for a server bug that the panel simply failed to render.

This page is part of Cross-Origin Debugging & Error Diagnosis, which covers reading browser errors, network inspection, and command-line reproduction of CORS failures.

What the Network Panel Shows for a Preflighted Request

The WHATWG Fetch Standard (§4.8, CORS-preflight fetch) defines a preflight as a separate OPTIONS request the browser sends before a non-simple request. In the Network panel this appears as two distinct rows to the same URL: the OPTIONS preflight, then the actual GET, POST, PUT, or DELETE once the preflight succeeds. Reading a CORS problem correctly means reading both rows as a pair — the request headers on the preflight declare intent, and the response headers declare what the server allowed.

Chrome labels the preflight row explicitly: the Type column reads preflight and, since Chrome 89, the actual request that follows carries a small chevron you can expand to reveal its preflight as a nested child row. That nesting is why a filter that hides the parent also hides the preflight — collapse state and filters interact, and a “missing” OPTIONS row is often just collapsed under its parent. The Status column is equally worth reading literally. A CORS-blocked request shows (blocked:cors) rather than a numeric status, because the browser never surfaced one to the page; a request that failed at the transport layer shows (failed) and a net::ERR_ code in the tooltip. Those two look similar in the list and mean completely different things: the first is a policy decision made after a successful HTTP exchange, the second is that no usable exchange happened at all.

The diagram below shows the two-request sequence the panel records when a preflighted request succeeds.

The two-request preflight sequence in the Network panel The browser first sends an OPTIONS preflight carrying Access-Control-Request headers. The server replies 204 with Access-Control-Allow headers. The browser validates them, then sends the actual POST request, which returns 200 with the response body. Browser Server OPTIONS /api (preflight) Access-Control-Request-Method / -Headers 204 No Content Access-Control-Allow-Origin / -Methods / -Headers Browser compares request vs allow POST /api (actual request) 200 OK + response body If the preflight fails, the actual request is never sent

Reference: Where Each Header Appears in the Panel

Header Direction Pane in DevTools What it tells you
Origin Request Request Headers The origin the browser attributed to the caller
Access-Control-Request-Method Request Request Headers The method the actual request will use
Access-Control-Request-Headers Request Request Headers The non-simple headers the actual request will send
Access-Control-Allow-Origin Response Response Headers The origin the server grants; must match Origin or be *
Access-Control-Allow-Methods Response Response Headers Methods the server permits; must include the requested method
Access-Control-Allow-Headers Response Response Headers Headers the server permits; must cover every requested header
Access-Control-Max-Age Response Response Headers How long the browser caches this preflight result
Access-Control-Allow-Credentials Response Response Headers Whether cookies/Authorization may accompany the actual request

Step-by-Step: Isolate and Read the Preflight

Step 1 — Enable Preserve Log Before Reproducing

A failed cross-origin request often coincides with a page reload or a single-page-app route change that clears the Network list. In Chrome DevTools, tick Preserve log at the top of the Network tab; in Firefox, click the gear icon and enable Persist Logs. Without this, the OPTIONS row can vanish before you inspect it.

Step 2 — Filter to the OPTIONS Request

Chrome exposes a method filter: type method:OPTIONS in the filter box to show only preflight rows. Firefox has no method token, so filter by the endpoint path or click the XHR/Fetch category, then sort by the Method column. Isolating OPTIONS removes the noise of unrelated document, script, and image requests.

Two more Chrome filter tokens repay learning here. has-response-header:access-control-allow-origin lists every response on the page that carries the header at all, which is the fastest way to find the one route that a proxy is answering differently from its siblings. -is:from-cache removes rows the browser served locally, so what remains is genuinely on the wire. Safari’s Web Inspector takes a different approach again: it has no filter tokens, but its Graphs view groups requests by resource type and its Network tab exposes a Preflight disclosure inside the entry detail rather than as a separate row, which is why a Safari-only report of “there is no preflight” is usually a reading error rather than a behavioural difference. When you need to hand the evidence to someone else, right-click any row and choose Save all as HAR with content: the HAR preserves both rows, their headers, and their timings, and it survives the page reload that would otherwise destroy the panel state.

Step 3 — Read the Request Side

Select the OPTIONS row and open the Headers pane. Under Request Headers, confirm the browser sent Origin, Access-Control-Request-Method, and — when your client adds custom headers — Access-Control-Request-Headers. These are generated by the browser, not your code; they are the browser’s declaration of what it is about to do.

Step 4 — Read the Response Side and Compare

Still in the Headers pane, scroll to Response Headers. Line up each request value against its permission:

A single missing token is the whole bug. Laid out as pairs, a failing preflight always looks like one row on the left with nothing to answer it on the right:

Pairing the request pane against the response pane Three pairs of headers. Access-Control-Request-Method is covered by Allow-Methods and Origin is matched exactly by Allow-Origin, but the authorization token in Access-Control-Request-Headers has no counterpart in Access-Control-Allow-Headers, which is the single defect that fails the preflight. Request Headers pane — what the browser asked Response Headers pane — what the server allowed Access-Control-Request-Method POST covered Access-Control-Allow-Methods GET, POST, OPTIONS Access-Control-Request-Headers authorization, content-type no match Access-Control-Allow-Headers content-type Origin https://app.example.com exact Access-Control-Allow-Origin https://app.example.com One uncovered token fails the whole preflight, and the actual request is never sent. Read the two panes as pairs, not as two lists — the defect is always a value on the left with no home on the right

The detailed walkthrough of this comparison lives in Reading a Preflight OPTIONS Request in DevTools.

Step 5 — Recognise the Provisional-Headers Gotcha

When a request is blocked, Chrome shows “Provisional headers are shown” with a caution icon, and the Response tab is empty. This does not mean the server sent nothing — it means the browser discarded the response before DevTools captured its headers. The panel is now blind to the truth. Switch to the command line: reproduce the exact request with curl to read the real response headers the server returned.

Edge Cases and Reading Pitfalls

The Response Tab Is Empty on a Blocked Request

For a CORS-blocked response, the browser withholds the body from the page, and DevTools mirrors that: the Response and Preview tabs are blank even though the server may have returned a full JSON body. An empty Response tab is expected on a blocked request and is not evidence of a server-side empty response.

A Cached Preflight Shows No OPTIONS Row

If a prior preflight returned Access-Control-Max-Age, the browser caches the decision and skips the OPTIONS request for subsequent calls within the TTL. Seeing only the actual request, with no OPTIONS row, can mean the preflight was cached — not that it never happened. Disable cache (the Disable cache checkbox) to force a fresh preflight while debugging. See how to set Access-Control-Max-Age effectively for how this TTL behaves.

The Timing Pane Isolates Preflight Cost

The Timing pane on the OPTIONS row shows the round-trip the preflight adds before the actual request can start. On a distant origin this latency is visible and measurable; if it dominates, the fix is caching or reducing preflights, not header edits.

Sketching the same endpoint under three conditions on one time axis shows what the panel is really telling you:

What the Network panel records for one endpoint under three conditions Three horizontal traces sharing one millisecond axis. The cold call shows an OPTIONS bar followed by the POST bar and costs the sum of both. The warm call, served from the preflight cache, shows only the POST bar. The blocked call shows only the OPTIONS bar, because the actual request is never issued. One endpoint, three traces — the row count alone tells you which case you are in Cold call no cached preflight OPTIONS preflight, 180 ms POST /api, 220 ms Warm call preflight cached POST /api, 220 ms — no OPTIONS row at all Blocked preflight rejected OPTIONS, 180 ms, then blocked the actual request is never issued, so no second row 0 100 ms 200 ms 300 ms 400 ms The Timing pane measures only the first bar; Access-Control-Max-Age is what removes it from every later call

null Origin and Sandboxed Contexts

Requests from sandboxed iframes, data: URLs, or some redirect chains carry Origin: null. In the panel this looks like a literal null string in the Request Headers, and the server must not blindly reflect it. Origin evaluation is covered in how browsers evaluate the same-origin policy.

A Redirect on the Preflight Is Never Followed

The Fetch Standard forbids following a redirect in response to a preflight. If your OPTIONS handler answers 301 or 308 — a very common accident when a trailing-slash rewrite or an HTTP-to-HTTPS rule sits in front of the API — the panel shows a single OPTIONS row with a 3xx status and no follow-up request, and the console reports that the preflight response is invalid. The row looks healthy because it is not an error status, which is precisely what makes this one slow to spot. Compare the URL in the Location response header with the URL your client requested: if they differ only by a trailing slash or a scheme, fix the client URL rather than the redirect rule, because the redirect will keep breaking the preflight no matter which target it points at.

Private Network Access Adds a Header You Did Not Send

When a page on a public origin requests a resource on a private or local address, Chromium adds Access-Control-Request-Private-Network: true to the preflight and requires Access-Control-Allow-Private-Network: true on the response. In the Request Headers pane this header appears without anything in your code having asked for it, and its absence from the response fails the preflight while every Access-Control-Allow-* header you did configure looks correct. It is the one case where the request side contains a token your application never generates, so the pairing habit from Step 4 still applies — you just have to recognise the extra pair.

The Initiator Column Points at the Code That Sent It

When several components call the same endpoint, the Initiator column on the actual request row links to the exact line that issued it; the OPTIONS row’s initiator is the browser itself, so it stays blank or names the parent request. Use the actual row, not the preflight row, to find the calling code — then decide whether the request needs to be preflighted at all. Removing one custom header can move a call back into the simple-request category described in Simple vs Preflight Requests, which deletes the OPTIONS row entirely rather than making it succeed faster.

Verification Checklist

Common Mistakes

Issue Technical impact Mitigation
Trusting “Provisional headers are shown” as the server response Developer chases a nonexistent server bug; the real headers were never rendered Reproduce with curl -i to read the actual response headers
Reading an empty Response tab as an empty server body Misdiagnosis; the body was withheld by the browser, not absent Confirm the body with curl outside the browser’s CORS enforcement
Not enabling Preserve log The OPTIONS row disappears on reload before it can be inspected Enable Preserve log / Persist Logs before triggering the request
Assuming no OPTIONS row means no preflight A cached preflight is skipped within its Access-Control-Max-Age TTL Tick Disable cache to force a fresh preflight
Comparing only the response headers Missing the request-side declaration that defines what must be allowed Read Access-Control-Request-* and Access-Control-Allow-* as a pair
Reading a 3xx on the OPTIONS row as harmless A redirect is never followed on a preflight, so the exchange ends there despite a non-error status Compare Location with the requested URL and fix the client URL
Treating (failed) as a CORS failure Time spent editing Access-Control-* headers for a DNS, TLS, or extension-cancelled request Confirm the transport error reproduces with curl before touching CORS config

FAQ

Why don’t I see an OPTIONS request in the Network panel?

Two reasons are common. First, the request may be a simple request that never triggers a preflight, so no OPTIONS row exists. Second, the panel may have cleared on navigation; enable Preserve log so entries survive the page reload the failed request often causes. If a preflight was cached via Access-Control-Max-Age, the browser skips it entirely and reuses the prior result.

What does ‘Provisional headers are shown’ mean on a CORS request?

It means the browser blocked the response before the real response headers were parsed, so DevTools can only display the tentative request headers it prepared, not what the server actually returned. It is a symptom of a failed or blocked request, not the cause. Reproduce the same request with curl to read the true response headers the server sent.

Should I read the request or the response headers to debug a preflight?

Both, as a pair. The Access-Control-Request-Method and Access-Control-Request-Headers request headers state what the browser intends to do. The Access-Control-Allow-Methods and Access-Control-Allow-Headers response headers state what the server permits. A preflight fails when a requested value is absent from the matching allow list, so you compare the two sides.

Does the Network panel show the preflight and the actual request as one row or two?

Two rows to the same URL: an OPTIONS entry for the preflight, followed by the actual GET, POST, PUT, or DELETE entry once the preflight passes. If the preflight fails, only the OPTIONS row appears and the actual request is never sent, because the browser aborts before it.

What is the difference between a status of (blocked:cors) and (failed) in the panel?

(blocked:cors) means the HTTP exchange completed and the browser then refused to expose the response to the page because the Access-Control headers did not permit it. (failed), usually with a net::ERR_ code in the tooltip, means no usable exchange happened: DNS, TLS, connection reset, or an extension cancelling the request. Only the first is a CORS problem. Reproduce a (failed) row with curl and you will see the same transport error outside the browser.

Why does my preflight fail when the OPTIONS row shows a 301 status?

The Fetch Standard forbids following a redirect in response to a preflight, so a 301 or 308 on the OPTIONS request ends the exchange there even though the status is not an error. The usual causes are a trailing-slash rewrite or an HTTP-to-HTTPS rule in front of the API. Compare the Location header with the URL your client requested and correct the client URL, because the redirect will break the preflight regardless of where it points.

Topics in This Section