A Chrome/Edge DevTools panel that shows you every CORS preflight your page makes, models the
browser's preflight cache the way the browser actually keys it, and tells you which of those
OPTIONS round trips you were paying for and did not need.
It is a single unpacked extension loaded from a clone of this repository. There is no build step, no bundler, no dependencies, and it is not published to any extension store.
- What it is
- Why it exists
- How the browser preflight cache actually works
- How this tool works
- Requirements
- Installing (load unpacked)
- Using the panel
- Every finding, explained
- The preflight tax and the other metrics
- Worked example
- The analysis engine as a library
- Running the tests
- Permissions, and why there are none
- Limitations and what this deliberately does not do
- Further reading
- Contributing
- Licence
preflight-cache-analyzer adds a Preflight panel to Chrome DevTools. While the panel is
open it watches the inspected page's network activity, picks out every CORS preflight, and
builds a table of them:
| column | meaning |
|---|---|
| Time | when the preflight started |
| Method | the value of Access-Control-Request-Method — the method the real request will use |
| URL | the URL being preflighted |
| Origin | the Origin that issued it |
| Status | the preflight response status; anything outside 2xx is a hard failure |
| Max-Age | Access-Control-Max-Age, and what the selected browser will clamp it to |
| Duration | wall-clock time of the OPTIONS round trip |
| Served | how many real requests this preflight's cache entry went on to serve |
Below the table sits a findings list. Each finding names the problem, quotes the evidence, explains the mechanism in a paragraph, and links an article that goes deeper.
The Network panel already shows you OPTIONS requests. What it does not show you is the thing
that matters: why there are so many of them. A preflight that repeats is not a preflight
problem, it is a cache-key problem, and the cache key is invisible — it is assembled by the
browser out of five separate inputs, three of which live on the request and two of which live
in the fetch call's configuration.
So the usual debugging session goes: notice OPTIONS in the waterfall, add
Access-Control-Max-Age: 86400, observe that nothing improves, conclude that preflight caching
"doesn't work". It works fine. Something else in the key is changing between calls, or the
value is being clamped, or the response is not 2xx, or the requests are simply not eligible to
share an entry.
This panel reconstructs the key so you can see which component is moving.
This is the part worth understanding; everything the tool reports follows from it.
When a cross-origin request needs a preflight, the browser first looks in its preflight result cache for a matching entry. The entry is keyed on, in effect:
- The request origin. The origin of the document making the call.
https://app.example.comandhttps://www.app.example.comare different origins; so arehttp://localhost:3000andhttp://127.0.0.1:3000, which is why a dev setup can preflight twice as often as production. - The request URL. Not the origin of the target, the whole URL.
/v1/orders/1and/v1/orders/2are two cache entries. This is the single biggest reason preflight caching under-delivers on REST APIs with per-resource URLs: a page touching fifty resources needs fifty cache entries, andAccess-Control-Max-Agecannot help with the first call to each. - The credentials mode. Whether the fetch was made with
credentials: 'include'. Entries populated by an uncredentialed request cannot serve a credentialed one, or the reverse. - The method, from
Access-Control-Request-Method. Methods are cached individually unless the response'sAccess-Control-Allow-Methodscovers a wider set, in which case the browser may store the wider set against the URL. - The header names, from
Access-Control-Request-Headers, as a set. Order is irrelevant — the browser normalises and sorts them. Membership is everything: add one conditional header and you have created a second, entirely separate cache entry.
Note what is not in the key: header values. An Authorization bearer token that rotates
every fifteen minutes does not invalidate anything. An X-Request-Id that is unique per call
does not invalidate anything either. What both of them do is force the preflight to exist in the
first place, which is a different and usually more expensive problem.
It is not an implementation detail; it is a security boundary. A response that answers an
uncredentialed preflight with Access-Control-Allow-Origin: * is perfectly valid. That same
wildcard is rejected by the browser the moment credentials are involved — a credentialed
response must echo the exact origin and add Access-Control-Allow-Credentials: true. If the two
modes shared a cache entry, a permissive uncredentialed answer could be reused to authorise a
credentialed request, which is exactly the escalation the wildcard rule exists to prevent. So
they are keyed apart, and you pay for two preflights when your code mixes modes against one
endpoint.
Access-Control-Max-Age is a request for a cache lifetime, not a grant of one. Each engine
imposes a ceiling:
| engine | ceiling |
|---|---|
| Chromium (Chrome, Edge, Brave, Opera) | 7200 s (2 hours) |
| Firefox | 86400 s (24 hours) |
| Safari / WebKit | 600 s (10 minutes) |
Values above the ceiling are clamped down, not rejected — the preflight still succeeds, it
just caches for less time than you asked for. Access-Control-Max-Age: 31536000 is not an
error; it is 7200 seconds in Chrome and 600 in Safari. Conversely, when the header is absent
Chromium falls back to a 5-second default, which in practice means every user-driven action
re-preflights.
A value of 0 disables caching for that entry. Negative values are treated the same way. Any
value that is not a bare integer — "3600", 1h, a duplicated header producing 3600, 3600 —
is discarded and you get the short default.
The preflight cache is not one global table. It is scoped to the network partition, which in current Chromium means it is keyed by the top-level site as well as the request details. Two tabs on the same site can share entries; a tab on a different top-level site cannot borrow yours, even for the identical API. Entries are dropped when the network state is reset, when the user clears browsing data, and — relevant when you are testing — whenever DevTools has "Disable cache" ticked, which in Chromium also bypasses the preflight cache. If you are measuring preflight behaviour with that box ticked, you are measuring the uncached path.
The panel's Clear on navigate toggle exists for the same reason: after a top-level navigation the previous recording no longer describes the cache state you are now in, so the default is to start fresh.
A cross-origin request is simple — no preflight — only when all of the following hold:
- the method is
GET,HEADorPOST; - every header is CORS-safelisted (
Accept,Accept-Language,Content-Language,Content-Type,Range, plus the client hints); Content-Type, if present, isapplication/x-www-form-urlencoded,multipart/form-dataortext/plain;- the body is not a
ReadableStream; - no listener is attached to the
XMLHttpRequest.uploadobject.
Break any one of those and you get an OPTIONS round trip. This matters for the tool's advice:
the best preflight is the one that never happens. Tuning Access-Control-Max-Age is the
second-best outcome, and the panel says so where it detects a request that is one header away
from being simple.
Collection is boring on purpose:
devtools/devtools.jsruns in the hidden DevTools page and registers the panel. That is all it does.panel/panel.jssubscribes to the DevTools network API'sonRequestFinishedevent and callsgetHAR()once at startup to backfill anything DevTools already recorded. Both hand back HAR 1.2 entries.- Every entry is appended to an in-memory array and the whole array is re-analysed. Analysis is cheap and re-running it from scratch avoids a class of incremental-state bugs.
src/analyzer.jsdoes the work. It contains no extension API calls at all, which is what lets the same file run under Node for the test suite.
The engine classifies each entry:
- Preflight — method is
OPTIONSand the request carriesAccess-Control-Request-Method. A bareOPTIONSprobe (a health check, a discovery call) is not a preflight and is ignored. - Cross-origin request — carries an
Originheader whose value differs from the target origin. Same-origin traffic is skipped entirely.
It then pairs each cross-origin request with the preflight whose cache entry the browser would have read it out of. Two rules decide it:
- Causal ordering. A
startedDateTimeincludes queueing, stalled and connection time, so a request queued at T0 is routinely recorded as starting before theOPTIONSthat caused it. Comparing start times therefore drops real pairings. What the browser does guarantee is that the actual request cannot have finished before its preflight finished, so the engine compares end times (startedAt + time) and picks the latest-ending preflight that qualifies. Where a recording carries no usable timings, HAR entry order stands in. - The header set.
(origin, URL, method)is not the whole cache key. Each request's preflight-triggering header set is reconstructed — every non-safelisted header name, pluscontent-typewhen its media type is outside the simple three — and matched against the preflight'sAccess-Control-Request-Headers. Under a header storm this is what stops a request attaching to whichever preflight merely happened to be most recent.
A preflight that did not return 2xx, or that was answered with a redirect, populated no cache entry and is excluded from pairing entirely.
Where no candidate matches a request's header set, the request is still attributed to the
latest-ending causally valid preflight rather than dropped — a dropped request understates the
preflight tax, which is the worse error. Those attributions are flagged: servedMatchExact on
the request, servedApproxCount on the preflight, approxPairedRequestCount in the metrics, and
a ~ beside the count in the Served column.
The pairing is what produces the "Served" column, and it is what lets the engine infer
credentials mode: a preflight never carries credentials itself, so the mode is read off the
requests it went on to serve (cookies present, or request.cookies populated). Where a
preflight served nothing, the response's Access-Control-Allow-Credentials is used as a
fallback signal. Every inferred value is marked as inferred in the exported JSON.
With keys assigned, preflights are grouped by full cache key and the findings engine runs.
- Chrome, Edge, or another Chromium browser, version 100 or newer.
- Node.js 14 or newer, but only if you want to run the test suite. The extension itself needs no Node.
There is nothing to install. No package registry is involved.
git clone <this repository>
cd preflight-cache-analyzerThen, in the browser:
- Open
chrome://extensions(oredge://extensions). - Turn on Developer mode — the toggle is in the top-right corner.
- Click Load unpacked.
- Select the cloned
preflight-cache-analyzerdirectory — the one containingmanifest.json. - Open DevTools on any page (F12) and find the Preflight tab alongside Elements, Console
and Network. It may be hidden behind the
»overflow chevron.
To pick up changes after editing the source, press the reload button on the extension's card in
chrome://extensions, then close and reopen DevTools.
To remove it, click Remove on the same card. Nothing is left behind: the extension stores no data and has no background page.
Recording. The panel records only while DevTools is open, so the usual sequence is: open
DevTools, switch to the Preflight tab, then reload the page. getHAR() backfills whatever
DevTools captured before you switched tabs, but it cannot recover anything from before DevTools
was opened.
Reading the table. Click any row to open the detail pane. It shows:
- the modelled cache key, component by component;
- the preflight's
Access-Control-*request headers side by side with the response's, so you can see immediately whether the server actually allowed what was asked for; - the list of real requests the preflight served, with their status and duration;
- any findings that cite this particular preflight.
A "Served" count of zero is worth a second look. It means the round trip bought nothing: either the preflight failed, or the request it was issued for was cancelled before it went out.
Controls.
- Clear discards the recording.
- Clear on navigate (on by default) discards it automatically when the inspected page navigates, because the browser's own cache state is not reliably carried across.
- The filter box matches URL, origin, method, status and requested header names.
- Cap switches which engine's
Access-Control-Max-Ageceiling the analysis assumes. Set it to Safari to see what your worst-supported browser experiences. - Export JSON downloads the complete analysis — metrics, cache-key groups, per-preflight detail and every finding. Useful for attaching to a ticket or diffing before and after a fix.
The panel follows the DevTools theme rather than the OS theme, via
chrome.devtools.panels.themeName.
Severities are critical, high, medium, low, info, and findings are listed in that
order.
The preflight response had a status outside 200–299. A preflight succeeds only on 2xx; 200 and
204 are the conventional choices. A 3xx, 4xx or 5xx fails it outright no matter how correct the
Access-Control-* headers are, and the real request is never sent. In JavaScript this surfaces
as a bare TypeError with no status, which is why it is so often mistaken for a network
outage. The most common cause by far is authentication middleware sitting in front of the CORS
handler: preflights never carry credentials, so they arrive unauthenticated and get a 401.
The preflight was answered with a redirect. Redirects are not followed for preflights — the
specification requires the OPTIONS to be answered directly. An HTTP-to-HTTPS upgrade, a
trailing-slash normalisation, or an SSO bounce placed in front of the API will therefore break
every preflighted call to it. Terminate OPTIONS at the first hop.
A preflight inferred to be credentialed was answered with Access-Control-Allow-Origin: *, or
with a * in Access-Control-Allow-Headers or Access-Control-Allow-Methods. The browser
rejects the wildcard origin outright when credentials are in play. The header and method
wildcards are read literally in credentialed mode — they match a header genuinely named *,
which nothing sends. Echo the exact origin, add Access-Control-Allow-Credentials: true, list
the headers and methods explicitly, and send Vary: Origin.
The preflight response carries no Access-Control-Max-Age. Chromium then caches the result for
5 seconds, which for most interaction patterns means every call re-preflights. Setting an
explicit value on the OPTIONS response is usually the single highest-leverage change
available.
Access-Control-Max-Age is present but is not a bare integer. Duration strings, quoted numbers
and duplicated headers are all discarded, and the response behaves as though the header were
absent.
Access-Control-Max-Age: 0 tells the browser not to cache the result at all; negative values
behave identically. Occasionally deliberate during a rollout, far more often a framework default
nobody revisited.
The same modelled cache key was preflighted again before the previous entry's stated lifetime had elapsed. The entry was therefore never usable. Look for a header set that differs between calls, a credentials-mode mismatch, or requests originating from a different tab or network partition, which does not share the cache.
Requests to the same (origin, URL, method) sent different sets of headers in
Access-Control-Request-Headers, and the finding names the inconsistent ones. Each distinct set
is a separate cache entry, so a header that is only sometimes present — a conditional auth
header, a feature-flag header, a debug header — multiplies your preflights instead of reusing
one. Send a stable header set on every call to the endpoint, even where a value would be empty,
or move the varying data out of the headers.
The same endpoint was called both with and without credentials. Two cache entries, two preflights, and a correctness trap if the server answers the uncredentialed case with a wildcard origin.
A recognised, usually-unnecessary custom header appears in Access-Control-Request-Headers —
X-Requested-With, X-Request-Id, X-Correlation-Id, tracing headers, static client-version
headers, and similar. Each entry carries specific advice. The point is not that these headers
are wrong, but that they are the reason a preflight exists at all: remove the last non-safelisted
header from a GET or POST and the request stops preflighting entirely.
The preflight is for a GET, HEAD or POST whose only non-safelisted input is a custom
header — and, where detected, a non-safelisted Content-Type. Move the value into a query
parameter, a cookie or the body, or send Content-Type: text/plain where the payload allows,
and the request becomes simple. Caching a preflight you never needed is the consolation prize.
A response echoed a specific Access-Control-Allow-Origin without Vary: Origin. Any shared
cache in the path — CDN, reverse proxy, corporate middlebox — can then store one origin's
response and serve it to another, which either breaks the second origin or grants it access it
was never given.
The 95th-percentile preflight exceeded the threshold (300 ms by default). A preflight is pure
latency: nothing renders and no data moves until it returns. A slow OPTIONS usually means the
request is traversing routing, session lookup and a database connection before anything answers
it. Answering OPTIONS at the edge with a static header block collapses the cost to one wire
round trip.
Access-Control-Max-Age exceeds the selected engine's ceiling. Harmless — it is clamped, not
rejected — but misleading, because your real worst case is whatever the strictest browser in
your traffic mix allows.
The same header set was sent in different orders. This does not split the cache, since the key uses the sorted set. It is reported because it usually means the header list is being assembled from an unordered structure, and the next header added to that structure conditionally will split the cache for real.
The summary bar carries seven numbers.
Preflight tax is the headline: preflight count × median preflight duration. It is the
wall-clock latency the page spent on OPTIONS round trips. It is deliberately a simple product
rather than a sum of measured durations, because it is meant to be reasoned about — "we made 40
preflights at ~120 ms, that is roughly five seconds of nothing" — not trusted to the
millisecond. Preflights that overlap in flight will make it an overestimate of elapsed time; it
remains an accurate count of latency incurred.
Preflights per request is the ratio of preflights to actual cross-origin requests. 0 is
perfect: either every request is simple, or every one hit a warm cache. 1 is the worst case,
one round trip bought for every call. Anything above 1 means preflights are being issued for
requests that did not complete.
The rest: preflights and cross-origin requests are raw counts; median and p95 describe the duration distribution; distinct cache keys is how many separate entries your traffic actually needs — compare it against the number of endpoints you think you are calling. A distinct-key count well above the endpoint count is the signature of a fragmenting key.
Running the engine over tests/fixtures/header-storm.har, a recording of a dashboard calling
one feed endpoint three times:
Metrics
preflightCount 3
corsRequestCount 3
pairedRequestCount 3
medianPreflightMs 380
p95PreflightMs 412
preflightTaxMs 1140
preflightRatio 1
requestsServedPerPreflight 1
distinctCacheKeys 2
Cache keys
https://dash.example.com | https://api.example.io/v2/feed | omit | GET | authorization,x-request-id x2 served=2
https://dash.example.com | https://api.example.io/v2/feed | omit | GET | authorization,x-feature-flags,x-request-id x1 served=1
Findings
HIGH header-set-varies
GET https://api.example.io/v2/feed produced 2 distinct header sets; the inconsistent header(s): x-feature-flags.
HIGH repeated-preflight-in-window
1 repeat(s) for GET https://api.example.io/v2/feed; the earliest came 52s after a preflight
that claimed 10 minutes of cache.
MEDIUM avoidable-custom-header
x-request-id appears in Access-Control-Request-Headers on 3 preflight(s).
MEDIUM preflight-avoidable-entirely
3 preflight(s) are for a GET, HEAD or POST whose only non-safelisted input is a custom header.
MEDIUM slow-preflight
The 95th-percentile preflight took 412ms, above the 300ms threshold. Slowest: 412ms.
Three calls, three preflights, 1.14 seconds of pure latency, and the reason is legible in the
cache keys: the second call added x-feature-flags, which created a second entry, and the third
call dropped it again and found the first entry already evicted. Access-Control-Max-Age: 600
was set correctly and bought nothing, because the key never stopped moving.
The fix is two changes, in order of value: send x-feature-flags unconditionally (or not at
all), which collapses two keys into one; then move x-request-id to a query parameter, which
removes the preflight altogether since GET with only safelisted headers is a simple request.
src/analyzer.js is a standalone module with no extension APIs. In Node:
const fs = require('node:fs');
const { analyze } = require('./src/analyzer.js');
const har = JSON.parse(fs.readFileSync('recording.har', 'utf8'));
const result = analyze(har, { browser: 'safari', slowPreflightMs: 200 });
for (const f of result.findings) {
console.log(`${f.severity}\t${f.id}\t${f.detail}`);
}In a browser it is a classic script that defines window.PreflightAnalyzer.
input may be a HAR document ({log: {entries: []}}), a bare array of HAR entries, or a single
entry. Anything else throws a TypeError. Malformed individual entries are skipped rather than
thrown on, so a partially corrupt recording still yields results.
options:
| option | default | meaning |
|---|---|---|
browser |
'chromium' |
Which Access-Control-Max-Age ceiling to apply: chromium, firefox or safari. An unknown value throws a RangeError. |
slowPreflightMs |
300 |
p95 threshold above which slow-preflight fires. |
Returns { generatedAt, options, metrics, preflights, requests, groups, findings }. The whole
object is JSON-serialisable; the panel's export writes a trimmed version of it.
Also exported for reuse and testing: isPreflightEntry, buildCacheKey,
isSafelistedRequestHeader, isSafelistedContentType, triggerHeaderSet, originOf,
median, percentile, MAX_AGE_CAPS,
SAFELISTED_REQUEST_HEADERS, SAFELISTED_CONTENT_TYPES, SEVERITY_ORDER, LINKS, DEFAULTS.
node tests/run-tests.jsNode built-ins only — no framework, no dependencies. The suite feeds six HAR fixtures through
the engine and asserts the findings, the cache-key grouping and the metrics, plus unit coverage
of origin parsing, key construction, percentiles and input validation. It exits 0 on success
and 1 with a failure report otherwise.
The fixtures in tests/fixtures/ are realistic recordings you can also read as documentation:
| fixture | what it demonstrates |
|---|---|
healthy-cached.har |
A correct configuration — one preflight, Max-Age: 7200, five credentialed requests served. Produces zero findings. |
no-max-age.har |
A JSON POST endpoint with no Access-Control-Max-Age; one preflight per call. |
header-storm.har |
A conditional header splitting the cache, plus a slow gateway. |
broken-preflight.har |
A 401 from auth middleware and a trailing-slash redirect. |
max-age-extremes.har |
86400, 0 and 24h as Max-Age values, and a reflected origin with no Vary. |
concurrent-burst.har |
Three identical concurrent JSON POSTs behind one preflight, with the queueing-skewed timings a real browser records. Pins the causal pairing. |
manifest.json declares "permissions": [] and "host_permissions": []. That is not an
oversight; it is the whole security posture of this extension.
devtools_pageis the only capability requested. It is not a permission — it is a declaration that the extension contributes to DevTools. Chrome grants the resulting page access to thechrome.devtools.*APIs, and to nothing else.chrome.devtools.networkrequires no permission entry. It only ever surfaces requests from the tab the developer has explicitly opened DevTools on, and only while it is open.- No
activeTab, notabs, nowebRequest, no<all_urls>. The extension never reads a page's DOM, never modifies a request, never sees a tab you have not opened DevTools on. - No background service worker. There is no work to do outside the panel's lifetime, so there is no persistent context. Close the panel and nothing of this extension is running.
- No storage. Recordings live in a JavaScript array in the panel and vanish when it closes. Nothing is written to disk unless you click Export JSON, and that is an ordinary download.
- No network access of its own. The panel never issues a request. The "Learn more" links are plain anchors that open in a normal tab when you click them; nothing is fetched otherwise.
The content security policy in the manifest pins script-src 'self', so no remote code can be
loaded even accidentally.
Chromium only. It relies on chrome.devtools.panels.themeName and on the Chromium DevTools
extension model. Firefox's browser.devtools API is close but not identical, and this has not
been adapted for it.
DevTools must be open before the requests happen. getHAR() backfills only what DevTools
itself already recorded. Anything from before DevTools was opened is unrecoverable — reload the
page after opening the panel.
"Disable cache" changes what you are measuring. With that box ticked, Chromium bypasses the preflight cache as well as the HTTP cache. You will see every preflight, which is occasionally what you want, but it is not what your users experience. Untick it for realistic measurements.
Cache hits are invisible. The tool sees requests that happened. A preflight served from cache produces no network entry at all, so the "Served" column is the closest available proxy for cache effectiveness — it is inferred from pairing, not observed.
Credentials mode is inferred, not observed. A HAR entry does not record the fetch's
credentials setting. The engine infers it from ambient cookies on the requests a preflight
served, falling back to the response's Access-Control-Allow-Credentials. A credentialed
request that happens to carry no cookies at the time will be read as uncredentialed. An
Authorization header set by application code is deliberately not treated as credentials,
because it is an ordinary custom header as far as the cache key is concerned.
HAR timings are approximate. entry.time includes queueing, connection setup and DNS. A
preflight on a fresh connection legitimately looks slower than one that reused a warm socket,
and the first preflight to a host will often dominate the p95 for that reason. Read the duration
column as an upper bound on server cost, not a measurement of it.
Pairing is exact where the recording allows it, approximate where it does not. A request is attached to the latest-ending preflight that could causally have served it and whose header set matches. That is deterministic: the same recording always yields the same answer, and concurrent traffic to one URL no longer moves the counts around. It stops being exact in one case — when no preflight in the bucket matches the request's header set, which happens when the preflight that populated the entry was made before recording started, or was served from a cache the recording never saw. The request is then attributed on timing alone so the totals stay honest, and both the panel and the exported JSON say so. Where several identical preflights race each other, the count is right and the row it lands on is arbitrary but stable — they share one cache key, so it is the same entry either way.
It does not modify traffic. No request is blocked, rewritten, replayed or injected. The extension is strictly an observer, and it makes no requests of its own — so there is no host to be authorised against and nothing here that probes a remote server.
It is not a CORS validator. It checks the preflight-caching and preflight-correctness surface. It will not audit your allowlist logic, test whether your origin reflection is exploitable, or tell you whether allowing a given origin is a good idea.
Background on the mechanics this tool reports, from cross-origin.com, a technical reference on CORS, preflight mechanics and browser security boundaries:
- how to set Access-Control-Max-Age effectively
- reducing preflight frequency with header caching
- designing lightweight OPTIONS endpoints
- measuring CORS preflight latency in production
- the performance cost of a preflight versus a simple request
- simple versus preflighted requests
- why preflight requests use the OPTIONS method
- reading a preflight OPTIONS request in DevTools
- understanding Access-Control-Allow-Credentials
- handling the Vary: Origin header correctly
- simulating a preflight with curl
See CONTRIBUTING.md.
MIT. See LICENSE.