Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

preflight-cache-analyzer

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.


Table of contents


What it is

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.

Why it exists

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.

How the browser preflight cache actually works

This is the part worth understanding; everything the tool reports follows from it.

The cache key

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:

  1. The request origin. The origin of the document making the call. https://app.example.com and https://www.app.example.com are different origins; so are http://localhost:3000 and http://127.0.0.1:3000, which is why a dev setup can preflight twice as often as production.
  2. The request URL. Not the origin of the target, the whole URL. /v1/orders/1 and /v1/orders/2 are 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, and Access-Control-Max-Age cannot help with the first call to each.
  3. 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.
  4. The method, from Access-Control-Request-Method. Methods are cached individually unless the response's Access-Control-Allow-Methods covers a wider set, in which case the browser may store the wider set against the URL.
  5. 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.

Why credentials mode splits the cache

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.

The caps

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.

Why the cache is narrower than you think

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.

What triggers a preflight at all

A cross-origin request is simple — no preflight — only when all of the following hold:

  • the method is GET, HEAD or POST;
  • every header is CORS-safelisted (Accept, Accept-Language, Content-Language, Content-Type, Range, plus the client hints);
  • Content-Type, if present, is application/x-www-form-urlencoded, multipart/form-data or text/plain;
  • the body is not a ReadableStream;
  • no listener is attached to the XMLHttpRequest.upload object.

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.

How this tool works

Collection is boring on purpose:

  1. devtools/devtools.js runs in the hidden DevTools page and registers the panel. That is all it does.
  2. panel/panel.js subscribes to the DevTools network API's onRequestFinished event and calls getHAR() once at startup to backfill anything DevTools already recorded. Both hand back HAR 1.2 entries.
  3. 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.
  4. src/analyzer.js does 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 OPTIONS and the request carries Access-Control-Request-Method. A bare OPTIONS probe (a health check, a discovery call) is not a preflight and is ignored.
  • Cross-origin request — carries an Origin header 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 startedDateTime includes queueing, stalled and connection time, so a request queued at T0 is routinely recorded as starting before the OPTIONS that 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, plus content-type when its media type is outside the simple three — and matched against the preflight's Access-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.

Requirements

  • 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.

Installing (load unpacked)

git clone <this repository>
cd preflight-cache-analyzer

Then, in the browser:

  1. Open chrome://extensions (or edge://extensions).
  2. Turn on Developer mode — the toggle is in the top-right corner.
  3. Click Load unpacked.
  4. Select the cloned preflight-cache-analyzer directory — the one containing manifest.json.
  5. 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.

Using the panel

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-Age ceiling 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.

Every finding, explained

Severities are critical, high, medium, low, info, and findings are listed in that order.

preflight-not-2xx — critical

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.

preflight-redirect — critical

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.

wildcard-with-credentials — critical

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.

no-max-age — high

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.

max-age-unparseable — high

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.

max-age-zero-or-negative — high

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.

repeated-preflight-in-window — high

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.

header-set-varies — high

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.

credentials-mode-split — medium

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.

avoidable-custom-header — medium

A recognised, usually-unnecessary custom header appears in Access-Control-Request-HeadersX-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.

preflight-avoidable-entirely — medium

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.

missing-vary-origin — medium

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.

slow-preflight — medium

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.

max-age-above-cap — low

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.

header-order-only-variation — info

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 preflight tax and the other metrics

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.

Worked example

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.

The analysis engine as a library

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.

analyze(input, options)

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.

Running the tests

node tests/run-tests.js

Node 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.

Permissions, and why there are none

manifest.json declares "permissions": [] and "host_permissions": []. That is not an oversight; it is the whole security posture of this extension.

  • devtools_page is 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 the chrome.devtools.* APIs, and to nothing else.
  • chrome.devtools.network requires 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, no tabs, no webRequest, 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.

Limitations and what this deliberately does not do

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.

Further reading

Background on the mechanics this tool reports, from cross-origin.com, a technical reference on CORS, preflight mechanics and browser security boundaries:

Contributing

See CONTRIBUTING.md.

Licence

MIT. See LICENSE.

About

Chrome DevTools panel that surfaces every CORS preflight, models the browser preflight cache, and flags redundant preflights costing you latency. Manifest V3, loaded unpacked, zero permissions.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages