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.
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:
Access-Control-Request-Method: POSTmust be covered byAccess-Control-Allow-Methods.- Every token in
Access-Control-Request-Headersmust be covered byAccess-Control-Allow-Headers. Originmust equalAccess-Control-Allow-Origin(or the server returns*for non-credentialed requests).
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:
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:
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.
Related
- Cross-Origin Debugging & Error Diagnosis — parent topic
- Reading a Preflight OPTIONS Request in DevTools — step-by-step read of a single preflight row
- Decoding Browser CORS Error Messages — mapping console text to causes
- Reproducing CORS Failures with curl — read the true server response the panel hides
- Simple vs Preflight Requests — when an OPTIONS preflight fires at all
Topics in This Section
Reading a Preflight OPTIONS Request in DevTools
Can't find the OPTIONS request or seeing a red CORS row? Enable preserve log, filter, then compare Access-Control-Request headers against the server's Allow headers.
Finding Which Layer Stripped the CORS Header
Your app sets Access-Control-Allow-Origin but the browser never sees it. Use per-hop stamp headers with DevTools and curl to name the layer that removed it.