Why Preflights Keep Repeating Despite Access-Control-Max-Age

Symptom. Every cross-origin POST or PATCH to your API issues two network requests — an OPTIONS preflight followed by the real request — on every single call, even after Access-Control-Max-Age is set on the server. The browser is not caching the preflight response at all.

Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass
access control check: No 'Access-Control-Allow-Origin' header is present.

Or in the network panel: an OPTIONS request on every fetch call, never a (disk cache) hit.

This page is part of Header Deduplication Techniques, which focuses on eliminating redundant header work in preflight flows.

Root Cause

The WHATWG Fetch Standard (§ 4.8 — “CORS-preflight cache”) mandates that a browser cache a preflight response only when all of the following are true: the OPTIONS response status is 200 or 204, Access-Control-Max-Age is present with a positive integer value, and the cached entry’s (origin, url, method, headers) tuple exactly matches the next request. If any element of that tuple differs — including a single extra header added dynamically — the browser treats it as a cache miss and fires a fresh preflight, ignoring the stored entry entirely. The header value itself is also subject to engine-level caps that silently truncate anything above the limit.

Prerequisites

Before applying this fix, confirm:

For a refresher on what a valid OPTIONS handler must return, see OPTIONS Endpoint Design.

Browser Engine Cache Caps

Rendering engines enforce hard maximums on Access-Control-Max-Age. The server cannot override these limits, and no console warning appears when the browser silently truncates a higher value.

Engine Maximum Cache Duration Behaviour on Exceeding Value
Chrome / Edge (Blink) 600 seconds (10 minutes) Silently truncated to 600
Firefox (Gecko) 86 400 seconds (24 hours) Silently truncated to 86 400
Safari / iOS (WebKit) 600 seconds (10 minutes) Silently truncated to 600

Cross-browser safe value: 600. Chrome, Edge, and Safari honour it in full; Firefox stores it as-is (well within its 24-hour ceiling). A value of 3600 appears to work in Firefox but is discarded by Blink and WebKit — those users continue to pay the preflight cost on every request after the first 10 minutes.

The diagram below shows the preflight cache lifecycle from the browser’s perspective:

Preflight cache lifecycle Flow diagram showing how the browser checks its preflight cache before issuing an OPTIONS request, and how a cache hit avoids the round-trip. Cross-origin fetch() call Preflight cache lookup (origin+url+…) OPTIONS request → server Send real request Store in cache TTL = Max-Age HIT MISS 204 + Max-Age send Preflight Cache Lifecycle HIT skips the OPTIONS round-trip; MISS fires OPTIONS and stores the response

Step-by-Step Fix

Step 1 — Return Access-Control-Max-Age only on OPTIONS responses.

On Nginx, guard the header with a method check so it never leaks onto GET/POST responses:

location /api/ {
  if ($request_method = 'OPTIONS') {
    add_header 'Access-Control-Allow-Origin'  'https://app.example.com';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH';
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
    add_header 'Access-Control-Max-Age'       '600';
    add_header 'Vary'                         'Origin';
    add_header 'Content-Length'               '0';
    add_header 'Content-Type'                 'text/plain';
    return 204;
  }
  proxy_pass http://backend;
}

Step 2 — Freeze the allowed-headers list.

Every unique value of Access-Control-Request-Headers in the incoming OPTIONS request generates a separate cache key. If your client adds X-Request-ID or X-Trace-ID on some calls but not others, the browser can never reuse a cached preflight for the “other” shape. Declare a fixed superset in Access-Control-Allow-Headers and strip per-request custom headers client-side wherever they are not needed.

Step 3 — Handle dynamic origins without breaking the cache.

When multiple origins are allowed, reflect the exact requesting origin and include Vary: Origin so the browser (and any intermediate CDN) treats each origin as its own cache partition:

app.options('/api/*', (req, res) => {
  const allowed = ['https://app.example.com', 'https://admin.example.com'];
  const origin = req.headers.origin;
  if (allowed.includes(origin)) {
    res.header('Access-Control-Allow-Origin', origin);
    res.header('Vary', 'Origin');
  }
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  // 600 is the cross-browser safe maximum (Blink + WebKit cap; Gecko allows more)
  res.header('Access-Control-Max-Age', '600');
  res.sendStatus(204);
});

Step 4 — Avoid per-request header mutation on the client.

Any header added dynamically — X-Request-ID: <uuid>, X-Timestamp: <ms> — changes Access-Control-Request-Headers, producing a cache miss on every call. Move such headers to query parameters or response metadata, or batch them into a single static header the server always permits.

Step 5 — Confirm credential-mode consistency.

If a request switches between credentials: 'omit' and credentials: 'include', the browser treats each mode as a distinct cache partition. Settle on a single credential mode per endpoint and never mix modes for the same URL.

Verification

curl one-liner — inspect the raw OPTIONS response headers before the browser gets involved:

curl -si -X OPTIONS \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: Content-Type, Authorization' \
  https://api.example.com/data \
  | grep -E 'HTTP|access-control|vary|content-length'

Expected output:

HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, PATCH
access-control-allow-headers: Content-Type, Authorization
access-control-max-age: 600
vary: Origin
content-length: 0

DevTools checklist — repeat calls must show a cache hit, not a new network request:

For a broader performance audit covering cache-duration tuning and Max-Age strategies, see the dedicated cluster on that topic.

Security Boundary

Do not set Access-Control-Allow-Origin: * on endpoints that process authenticated requests. A wildcard origin is prohibited when credentials: 'include' is in use — the browser rejects it and the preflight cache entry is never created. Reflect the exact requesting origin and pair it with Vary: Origin instead. For the full treatment of dynamic origin validation patterns, see the server-side configuration reference.

Common Mistakes

Issue Technical Explanation Fix
Access-Control-Max-Age on GET/POST responses Browsers only store preflight cache entries when the header appears on OPTIONS 204/200. The header is ignored on any other status code or method. Return the header exclusively in the OPTIONS handler.
Setting a value above 600 and expecting it to work in Chrome Blink silently truncates to 600; Chrome users still pay the preflight cost on every request after 10 minutes even though the header says more. Use exactly 600 for cross-browser coverage.
Adding per-request headers like X-Request-ID to the fetch call Each distinct Access-Control-Request-Headers value is a separate cache key. The cache entry for the previous header set cannot be reused. Move per-request metadata to query params or a single static custom header.
Omitting Vary: Origin with dynamic origin reflection Without Vary: Origin, a CDN or shared cache can serve origin A’s preflight response to origin B, producing incorrect or rejected CORS headers. Always include Vary: Origin when reflecting dynamic origins.

FAQ

Does Access-Control-Max-Age work with credentials: 'include'?

Yes. Credentialed preflights are cached by the same mechanism, keyed on (origin, url, method, headers). The server must reflect the exact requesting origin — not * — for the cache entry to be valid and reused on subsequent calls.

Why does Safari appear to ignore a 24-hour preflight cache?

WebKit enforces a hard 600-second cap. Any value above 600 is silently truncated to 600 without a console warning. Safari users will always see a fresh preflight after 10 minutes, regardless of what the server sends.

How do I invalidate the preflight cache after a deployment?

Change the value of Access-Control-Allow-Methods or Access-Control-Allow-Headers in your OPTIONS response, or modify the URL path (e.g. /v2/). Either change alters the cache key tuple, forcing browsers to issue a new OPTIONS request and store the updated response.