Browser Preflight Cache Limits

A preflight cache entry is the only thing standing between a non-simple cross-origin call and a doubled request count, and almost every property that governs it lives inside the browser rather than on your server. You send one number in Access-Control-Max-Age; the engine decides what to store, how long to keep it, which requests may reuse it, and when to throw it away early. Configurations that look identical in a config file therefore behave differently on Chrome, Firefox and Safari, and the difference is invisible in the response headers. This page is part of Preflight Request Optimization & Caching Strategies, and it documents the browser-side constraints — the ceilings, the key, the partitions and the eviction triggers — that decide whether the TTL you configured means anything at all.

The practical consequence is a planning problem. A team measuring preflight volume on a Firefox-heavy internal tool and a team measuring the same API from a Chrome-heavy consumer app will reach opposite conclusions about whether their caching is working, from the same server configuration. Reading the engine limits first turns that into an arithmetic exercise instead of an argument.

Spec anchor: what the standard actually mandates

The WHATWG Fetch Standard defines the CORS-preflight cache as a list of entries, each holding a key made of the byte-serialized origin, the URL, the credentials mode, and either a method or a header name, together with a max-age value. Two rules in that definition do most of the work:

  1. The engine “may” cache. Caching a preflight result is permitted, never required. A conforming browser that stores nothing at all is still conforming, which is why no server-side configuration can guarantee a hit.
  2. The Access-Control-Max-Age value is an upper bound the engine “may” reduce. The standard explicitly allows an implementation-defined maximum, and every shipping engine defines one. Nothing in the response tells you what that maximum is.

Note the shape of the key in the specification: an entry records one method or one header name, not the whole list. A preflight that advertised PATCH plus Authorization plus Content-Type produces several entries, and a later request is only exempt from preflighting when every method and header it needs is covered by a live entry. That is why adding one new header to a client library invalidates nothing yet still forces a fresh round trip — the new header simply has no entry.

The other half of the mechanism is not in the Fetch Standard at all. Network-state partitioning, memory pressure eviction and profile lifetime are browser-engineering concerns, and they cut TTLs short far more often than expiry does.

Engine ceilings at a glance

The number your server sends is a request, not an instruction. Each engine compares it to a hard maximum and stores the smaller of the two, silently.

Engine Shipping browsers Stored TTL ceiling Behaviour above the ceiling Behaviour when the header is absent
Blink Chrome, Edge, Opera, Brave, Chromium-based embeds 600 s Clamped to 600 s, no console warning Defaults to a 5 s entry
Gecko Firefox, Firefox for Android 86 400 s Clamped to 86 400 s Defaults to a 5 s entry
WebKit Safari on macOS and iOS, every iOS browser 600 s Clamped to 600 s Defaults to a 5 s entry

Two details are worth separating out because they are frequently conflated. First, a value above the ceiling is clamped, not rejected — the entry is still created, it simply lives for less time than requested, which is why the symptom is “preflights come back sooner than expected” rather than “caching stopped working”. Second, the absent-header default is a real, non-zero entry: a server that emits no Access-Control-Max-Age at all still gets a few seconds of deduplication, which is enough to hide the problem during a burst of parallel calls and expose it during steady traffic.

Because every iOS browser is required to use WebKit, the 600 s ceiling covers the entire iOS install base regardless of which browser icon the user taps. There is no configuration that makes an iPhone honour a one-hour TTL.

The clamp is easiest to read on a single axis, with each engine’s ceiling drawn as a wall the requested value cannot cross:

Requested TTL versus stored TTL per engine Three horizontal lanes share one non-linear seconds axis. The Blink and WebKit lanes stop at a ceiling wall drawn at 600 seconds, with a clamp arrow pulling a requested 86400 value back to that wall. The Gecko lane runs the full width to its own ceiling at 86400 seconds. Server sends Access-Control-Max-Age: 86400 Blink Chrome, Edge stored: 600 s clamped down, silently WebKit Safari, all iOS stored: 600 s clamped down, silently Gecko Firefox stored: 86 400 s — the request fits under this engine's own ceiling 600 s wall 86 400 s wall 0 60 600 3 600 86 400 Axis in seconds, not to scale — one header value produces two entirely different traffic profiles

Header and parameter reference

These are the fields a browser reads when deciding whether to create, reuse, or refuse a preflight cache entry. Everything else in the OPTIONS response is ignored for caching purposes.

Field Where it appears Values that create a usable entry What breaks the entry
Access-Control-Max-Age OPTIONS response A non-negative integer in decimal ASCII Any non-integer token (10m, 600s, six hundred) fails to parse and falls back to the 5 s default
Access-Control-Allow-Methods OPTIONS response Comma-separated method tokens, uppercase A method the later request uses but the header omits means no entry covers it
Access-Control-Allow-Headers OPTIONS response Comma-separated header names, case-insensitive Same rule: an uncovered header name forces a fresh preflight
Access-Control-Allow-Origin OPTIONS response The exact request origin, or * when credentials are not in use A mismatch fails the preflight outright, so nothing is stored
Access-Control-Allow-Credentials OPTIONS response true when the request carried credentials Absent on a credentialed request means the preflight fails and no entry is created
Response status OPTIONS response Any 2xx — 204 and 200 are both fine A 3xx, 4xx or 5xx status fails the preflight; nothing is cached
Credentials mode Request-side, from fetch or XMLHttpRequest omit, same-origin or include, part of the key Switching modes on the same URL uses a different entry set
Top-level site Browser-internal partition The site of the outermost document Same API embedded under a different site gets a separate partition

The last two rows are the ones that produce “it works on my machine” reports. Neither is visible anywhere in the response, and neither can be influenced from the server.

How the partitioning multiplies your entry count

Developers usually picture a single entry per API endpoint. What actually exists is a tree: the browser first partitions by the top-level site, then within that partition by the request origin and URL, then by credentials mode, and finally by the individual method and header names the preflight authorised. A “600 second cache” that appears to be one entry is often a dozen, each warmed independently and each paying its own first-request preflight.

One endpoint, many preflight cache entries A four-level tree starting from a single API endpoint. It branches first by top-level site partition, then by credentials mode, then by the individual methods and header names an entry may cover. Each leaf is a separately warmed entry that pays its own first preflight. POST /v3/shipments partition by top-level site console.northwind.dev partner.tessellate.io status.northwind.dev then by credentials mode omit include include omit then one entry per method and per header name method POST content-type x-tenant-id method POST content-type authorization method POST content-type authorization method POST content-type x-trace-id 12 entries from one endpoint Each leaf is warmed by its own first request — the TTL never amortises across siblings Shrinking the header set is worth more than raising the TTL

The lesson from that shape is that the highest-leverage optimisation is almost never the TTL. Collapsing four conditional client headers into one always-sent header removes three quarters of the leaves, and it works on every engine at once. The same reasoning drives the techniques in Header Deduplication Techniques for CORS Preflight Optimization, which attacks the header-set axis directly rather than arguing with the ceiling.

Step-by-step: configuring for the ceilings you actually have

Step 1 — Pick the value your worst-case engine will keep

There is exactly one value that is fully honoured everywhere: 600. Anything larger is a Firefox-only optimisation. Decide deliberately which of the two you are doing, and write the decision into the config as a comment so the next person does not “fix” it.

# /etc/nginx/conf.d/shipments-cors.conf
map $http_origin $shipments_cors_origin {
    default                            "";
    "https://console.northwind.dev"    $http_origin;
    "https://status.northwind.dev"     $http_origin;
}

server {
    listen 443 ssl;
    http2 on;
    server_name gateway.northwind.dev;

    location /v3/ {
        if ($shipments_cors_origin != "") {
            add_header Access-Control-Allow-Origin $shipments_cors_origin always;
        }
        add_header Vary Origin always;

        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Tenant-Id" always;
            # 600 is the largest value Blink and WebKit will store. Raising it
            # only changes behaviour in Gecko; see the ceiling table.
            add_header Access-Control-Max-Age 600 always;
            return 204;
        }

        proxy_pass http://shipments_upstream;
    }
}

Step 2 — Emit a per-engine value only if you will measure it

If Firefox is a material share of your traffic and preflight volume is a real cost, you can branch on User-Agent and hand Gecko a longer TTL. This is a legitimate optimisation and also a trap: Vary: User-Agent on a preflight fragments every downstream cache, and a wrong guess is worse than no branch. Do it in one place, and only behind a measurement.

// preflight-ttl.js — Express 4/5 middleware
const ALLOWED = new Set([
  'https://console.northwind.dev',
  'https://status.northwind.dev',
]);

// Gecko stores up to 86400 s; Blink and WebKit clamp at 600 s.
const GECKO_TTL = '86400';
const UNIVERSAL_TTL = '600';

function preflightTtl(req, res, next) {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin, User-Agent');
  if (!origin || !ALLOWED.has(origin)) return next();

  res.setHeader('Access-Control-Allow-Origin', origin);

  if (req.method !== 'OPTIONS') return next();

  const ua = req.headers['user-agent'] || '';
  const isGecko = /Gecko\/|rv:\d/.test(ua) && !/like Gecko/.test(ua);

  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-Id');
  res.setHeader('Access-Control-Max-Age', isGecko ? GECKO_TTL : UNIVERSAL_TTL);
  return res.status(204).end();
}

module.exports = preflightTtl;

The like Gecko exclusion matters: every WebKit and Blink user agent string contains the token like Gecko, so a naive substring test hands Safari and Chrome a value they will clamp anyway — harmless in effect, but it makes the metric you are about to collect meaningless.

Step 3 — Freeze the client’s request-header set

The ceiling is a fixed cost you cannot lower. The number of leaves in the tree above is entirely yours. Send the same header names on every call to a route, even when a value is empty, so the same entry keeps getting reused.

// api-client.js — one place that decides the header shape of every call
const FIXED_HEADERS = ['Content-Type', 'Authorization', 'X-Tenant-Id'];

export async function callGateway(path, body, { token, tenantId }) {
  const headers = new Headers();
  headers.set('Content-Type', 'application/json');
  headers.set('Authorization', `Bearer ${token}`);
  // Always sent, even when unknown — an empty value keeps the key stable,
  // an omitted header creates a second cache entry.
  headers.set('X-Tenant-Id', tenantId ?? '');

  const response = await fetch(`https://gateway.northwind.dev${path}`, {
    method: 'POST',
    credentials: 'include',
    headers,
    body: JSON.stringify(body),
  });
  console.assert(
    [...headers.keys()].length === FIXED_HEADERS.length,
    'header set drifted — a new preflight entry will be created'
  );
  return response.json();
}

Step 4 — Measure the hit rate from the server side

Because a cache hit produces no request, the only observable is the ratio between preflights and the non-simple actual requests that follow them. Log both and divide.

// preflight-metrics.js — attach before the CORS middleware
const counters = { options: 0, nonSimple: 0 };

function preflightMetrics(req, res, next) {
  if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
    counters.options += 1;
  } else if (req.headers.origin && req.method !== 'GET' && req.method !== 'HEAD') {
    counters.nonSimple += 1;
  }
  next();
}

setInterval(() => {
  if (counters.nonSimple === 0) return;
  const ratio = counters.options / counters.nonSimple;
  console.log(JSON.stringify({
    metric: 'preflight_ratio',
    value: Number(ratio.toFixed(3)),
    hint: ratio > 0.5 ? 'entries are not being reused' : 'cache is working',
  }));
  counters.options = 0;
  counters.nonSimple = 0;
}, 60_000).unref();

module.exports = preflightMetrics;

A ratio near 1.0 means essentially nothing is being cached. A healthy stable API on a 600 s TTL usually lands well below 0.1. The same instinct — treat preflight volume as a measurable quantity rather than a belief — underpins Measuring CORS Preflight Latency in Production.

Eviction: everything that ends an entry before its TTL

Expiry is the least common way an entry disappears. The browser drops preflight state for reasons that have nothing to do with the number you sent, and none of them generate an error or a log line.

What ends a preflight entry before its TTL A horizontal life bar spans a nominal 600 second TTL. Five labelled cut points along the bar mark events that terminate the entry early: a network change, clearing site data, closing a private window, renderer discard under memory pressure, and a profile restart. Only the far right end represents natural expiry. One entry, nominal lifetime 600 s entry is live and reusable — no OPTIONS request is sent expiry network change Wi-Fi, VPN, cellular clear site data from settings or DevTools private window shut its store is discarded renderer discarded memory pressure browser restart the store is memory-only 0 s 150 s 300 s 450 s 600 s None of these events emits a console message — the only symptom is an unexpected OPTIONS request Size the TTL to reduce steady-state volume; never let correctness depend on an entry still existing

Network state changes flush aggressively. A laptop moving from Wi-Fi to a docking station’s Ethernet, a phone handing off from cellular to Wi-Fi, or a corporate VPN connecting all invalidate network-scoped caches, preflight entries included. On mobile this can happen several times an hour, which puts a hard floor under real-world preflight volume no TTL can lower.

The store is memory-resident. No engine persists preflight entries to disk. Quitting the browser, or the operating system reclaiming a background tab, starts every partition cold. This is the difference between a benchmark measured over five minutes of continuous clicking and the same app used in ten-second bursts across a working day.

Private and container windows are separate universes. A private window creates its own partition and destroys it on close. Firefox container tabs behave the same way for the same reason. This is why “I cannot reproduce the caching” is so often a report from someone testing in a private window.

Security boundaries around the cache

The preflight cache is a small piece of shared state, and every limit on it exists partly for privacy reasons rather than performance ones.

Partitioning is a tracking defence, not an optimisation. If the cache were global, a third-party API embedded on two unrelated sites could infer that the same browser visited both, purely from the presence or absence of a preflight request. Partitioning by top-level site removes that channel. It also means you cannot “warm” a partner’s partition from your own site, no matter how the request is issued.

A cached grant is a grant. An entry records that the server said PATCH and Authorization are acceptable from this origin. If you tighten a policy — remove a method, drop an origin from the allowlist — browsers holding a live entry keep acting on the old answer for up to the stored TTL. There is no revocation mechanism. Security-relevant tightening must therefore be enforced at the actual request, not by narrowing the preflight response; the request-level check is what stops an attacker, as covered in CORS Security Auditing & Hardening.

Credentials mode is part of the key for a reason. An entry created by an uncredentialed request must not authorise a credentialed one, because the two require different guarantees from Access-Control-Allow-Origin and Access-Control-Allow-Credentials. This is enforced by the engine, and it is why switching a client from credentials: 'omit' to 'include' produces a burst of fresh preflights that looks like a regression.

Never encode secrets in the preflight response. Some teams echo tenant identifiers or internal route names into Access-Control-Allow-Headers. That response is cached in the browser, survives navigation within the partition, and is readable by any script in the same document via a subsequent failed request’s error surface. Keep it generic.

Proxy and CDN interaction

Nothing between the browser and your origin can see, extend, or invalidate a browser-side preflight entry — but intermediaries change what gets stored in the first place, and that is where the surprises are.

A CDN that caches OPTIONS freezes the ceiling problem in place. If an edge node stores a preflight response with Access-Control-Max-Age: 3600 and you later correct it to 600, browsers keep receiving the old value until the edge entry expires, and each of those browsers then stores its own clamped copy. Configure OPTIONS to bypass the edge cache, or purge deliberately on every CORS policy change. The provider-specific behaviour here is covered in Troubleshooting CORS at the Proxy Layer.

A proxy that rewrites the header count changes the key. Layers that inject tracing headers into the request — some service meshes and API gateways add a correlation header at the edge — do not affect the browser’s key, because the browser computed Access-Control-Request-Headers before the proxy saw anything. But a proxy that strips Access-Control-Allow-Headers entries from the response narrows what the entry covers, and the next request needing the stripped header preflights again.

Terminating preflight at the edge does not change the ceiling. Answering OPTIONS in an edge function removes the origin round trip, which is a large latency win, but the browser still clamps whatever TTL the edge returns. Edge termination and TTL selection are independent decisions; see Proxy Bypass Strategies for CORS Preflight for the termination side.

The layers are easiest to keep straight when you see which header each one obeys:

Which layer obeys which caching header Four horizontal bands stack from the browser preflight store at the top down through the browser HTTP cache, the CDN edge and the origin server. Each band names the header that controls it, showing that Access-Control-Max-Age is read only by the topmost band and is inert everywhere else. Browser preflight store partitioned, memory-only, keyed on origin plus URL plus mode plus method or header name Access-Control- Max-Age Browser HTTP cache stores the actual response body; never consulted for a preflight decision Cache-Control ignores Max-Age CDN or reverse proxy may cache the OPTIONS response itself, freezing a stale TTL for every cold browser Cache-Control Vary Origin server the only place the value is chosen — and the only place it is ever a plain integer emits the header The value travels up through three layers that ignore it, to one layer that clamps it

DevTools and curl verification checklist

Work through this after any change to a preflight response, and repeat it in at least one Blink browser and one Gecko browser — a single-engine check cannot detect a clamp.

Common mistakes

Issue Technical impact Mitigation
Setting a TTL above 600 s and assuming it applies everywhere Chrome, Edge and every iOS browser clamp to 600 s; the preflight volume you budgeted for never materialises Treat 600 as the universal value; branch on the engine only behind a measurement
Sending a non-integer value such as 10m or "600" The value fails to parse and the engine falls back to a 5 s entry, so preflights fire almost every call Emit a bare decimal integer; assert it in a test that reads the raw header bytes
Treating an absent OPTIONS row in DevTools as a bug A cache hit shows nothing at all in the Network panel, which reads as “the request vanished” Confirm with Disable cache ticked, where the row always appears
Expecting a single entry per endpoint Partition, credentials mode, method and header name each split the entry set; hit rate is a fraction of the naive estimate Freeze the client header set and keep one credentials mode per URL
Relying on a live entry for correctness during a policy rollout Tightening a policy does not revoke cached grants; widening one is not seen until the old entry expires Sequence rollouts server-first, wait longer than the TTL, or change the URL so no entry applies
Benchmarking preflight volume in a private window Its partition is discarded on close and starts cold, so the measured hit rate is close to zero Measure in a normal profile, over a realistic session length
Caching OPTIONS at the CDN with a long edge TTL A corrected TTL cannot reach cold browsers until the edge entry expires Bypass the edge cache for OPTIONS, or purge on every CORS policy change

FAQ

Is there a limit on how many preflight entries a browser will store?

Yes, but it is an implementation detail with no specified value and no way to query it. Every engine keeps the preflight cache in renderer or network-process memory with a bounded size, and entries are dropped under pressure without notice. Treat capacity as unreliable: design so that a miss costs one extra round trip, never a functional failure. If a single page fans out to dozens of distinct endpoint-and-header combinations, assume the oldest entries are already gone by the time the user acts.

Does a longer Access-Control-Max-Age make preflights less frequent across all engines?

Only up to each engine’s ceiling. Blink and WebKit clamp the stored TTL at 600 seconds, so raising the header from 600 to 86400 changes nothing for Chrome, Edge or Safari users. Gecko stores up to 86 400 seconds, so the same header does reduce preflight volume in Firefox. A single header value therefore produces two very different traffic profiles, which is exactly why a Firefox-only benchmark is misleading. The full clamping mechanism is covered in Why Your Access-Control-Max-Age Is Ignored.

Why do two tabs on different sites not share a preflight entry for the same API?

All three engines partition network state by the top-level site the request was made under. An entry created while browsing one embedding site is not visible to a page embedded on another, even when the request origin, URL, method and header set are byte-identical. This is a privacy boundary and cannot be opted out of from the server, so an API embedded across many partner sites should expect a first-request preflight per partner rather than one globally.

Can a server detect that a browser used a cached preflight?

Not directly. A cache hit means no OPTIONS request arrives, so the only server-side signal is the absence of an expected preflight. Compare the count of OPTIONS requests to the count of non-simple actual requests per route over a window; the gap is your hit rate. A ratio near one to one means entries are missing or being evicted, and the header set is usually the reason before the TTL is.

Does a service worker have its own preflight cache?

No. A service worker does not own a separate preflight store. Requests it makes with fetch() go through the same partitioned cache as requests from the controlled page, so a worker call can reuse an entry the page created and vice versa, provided the key tuple and the partition match. What a service worker can do is change the header set — adding an auth header inside the worker that the page did not send creates a second entry.