Cache Duration Tuning & Max-Age
Without a correctly tuned Access-Control-Max-Age, every non-simple cross-origin request pays an extra OPTIONS round-trip before the browser sends the real request. At scale this adds measurable latency, inflates origin request counts, and can exhaust rate-limit quotas before real traffic reaches your API. This page is part of Preflight Request Optimization & Caching Strategies and covers the mechanics and configuration steps needed to eliminate those redundant round-trips.
Spec anchor: how the browser uses Access-Control-Max-Age
The WHATWG Fetch Standard (§4.8, “CORS-preflight fetch”) defines Access-Control-Max-Age as the number of seconds a successful preflight response may be reused without issuing another OPTIONS request. “Successful” means the response included the required Access-Control-Allow-Origin, Access-Control-Allow-Methods, and where necessary Access-Control-Allow-Headers headers.
The cache key for a stored preflight entry is the tuple: (requesting origin, resource URL, HTTP method, sorted Access-Control-Request-Headers). A request that differs on any axis is a cache miss and triggers a new OPTIONS round-trip, regardless of the stored entry’s remaining TTL.
Walking one stored entry against five follow-up requests shows how narrow that match actually is:
Header and parameter reference
| Header / Parameter | Type | Allowed values | Default (absent) | Browser TTL ceiling |
|---|---|---|---|---|
Access-Control-Max-Age |
Response header (integer string) | 0 to engine-specific max |
Browser-defined (typically 5 s) | Chrome/Safari: 600 s · Firefox: 86 400 s |
Vary |
Response header | Origin (minimum) |
No isolation | Required on all preflight responses |
Access-Control-Allow-Methods |
Response header | Comma-separated HTTP methods | No cached permission | Part of the cache key scope |
Access-Control-Allow-Headers |
Response header | Comma-separated header names | No cached headers | Part of the cache key scope |
Two properties of that store surprise people. First, the credentials mode is effectively part of the key: an entry created by a fetch() with credentials: 'omit' is not reused by a later call with credentials: 'include', because the two need different guarantees from the response. Second, the cache is scoped per network partition, not per tab. Chromium partitions it by the top-level site, so the same API called from https://app.example.com and from an embedded frame on https://partner.example maintains two independent entries even though the request origin string is identical. Both properties mean the real-world hit rate is lower than a naive “one preflight per ten minutes” estimate.
Setting Access-Control-Max-Age: 600 is the cross-browser safe maximum: Chrome and Safari honor it in full, and Firefox also honors it (Firefox’s ceiling is 86 400 s). Values above 600 s are silently clamped by Chrome and Safari. Setting 0 or a negative value forces a fresh OPTIONS request on every cross-origin call.
Browser TTL enforcement at a glance
Step-by-step implementation
Route volatility is the primary input to TTL selection. Static asset or stable API endpoints can carry 600 s. Rapidly evolving GraphQL schemas or A/B-tested endpoints with changing allowed headers warrant 60–300 s to avoid stale policy caches that block new headers or methods.
Step 1 — Classify route volatility
Categorize each route before touching configuration:
| Route type | Recommended TTL | Rationale |
|---|---|---|
Public static assets (/static/, /fonts/) |
600 s | Headers never change between deploys |
Stable REST endpoints (/api/v2/users) |
600 s | Method/header set fixed per version |
Versioned but volatile REST (/api/v2/items with evolving schema) |
300 s | Allows 5-minute rollout window |
| Dynamic GraphQL or feature-flagged endpoints | 60–120 s | Schema or header requirements can change per request |
| Zero-trust re-validation required | 0 | Forces preflight on every call; use only when you need server-side auth on every OPTIONS request |
Step 2 — Nginx: inject Access-Control-Max-Age and Vary
# /etc/nginx/conf.d/cors.conf
map $request_method $cors_max_age {
OPTIONS 600;
default "";
}
server {
listen 443 ssl http2;
server_name api.example.com;
location /api/ {
# Pass CORS headers for actual requests
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://upstream_backend;
}
# Volatile endpoint: lower TTL, forward to origin
location /api/graphql {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, Apollo-Require-Preflight" always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Max-Age 120 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://upstream_backend;
}
}
The always modifier ensures Nginx attaches these headers even on 4xx and 5xx responses — critical for debugging failed preflights.
Step 3 — Node.js / Express: dynamic TTL middleware
// cors-preflight.middleware.js
const ROUTE_TTL = new Map([
['/api/static-assets', 600],
['/api/v2/users', 600],
['/api/v2/items', 300],
['/api/graphql', 120],
]);
function getMaxAge(path) {
for (const [prefix, ttl] of ROUTE_TTL) {
if (path.startsWith(prefix)) return ttl;
}
return 300; // safe default for unclassified routes
}
function corsPreflightMiddleware(req, res, next) {
const origin = req.headers.origin;
if (!origin) return next();
// Always set Vary to prevent CDN cache poisoning
res.setHeader('Vary', 'Origin');
if (req.method === 'OPTIONS') {
const maxAge = getMaxAge(req.path);
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID');
res.setHeader('Access-Control-Max-Age', String(maxAge));
// Do NOT set Access-Control-Max-Age twice — duplicates corrupt the cache key
return res.status(204).end();
}
res.setHeader('Access-Control-Allow-Origin', origin);
next();
}
module.exports = corsPreflightMiddleware;
Avoid duplicate Access-Control-Max-Age headers. Conflicting values corrupt the preflight cache key. Follow the same deduplication discipline enforced by header deduplication techniques to prevent undefined browser behavior.
Step 4 — Python / FastAPI: route-level TTL
# main.py
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
ROUTE_MAX_AGE: dict[str, int] = {
"/api/v2/users": 600,
"/api/v2/items": 300,
"/api/graphql": 120,
}
app = FastAPI()
@app.middleware("http")
async def cors_preflight(request: Request, call_next):
origin = request.headers.get("origin", "")
if not origin:
return await call_next(request)
if request.method == "OPTIONS":
max_age = next(
(v for k, v in ROUTE_MAX_AGE.items() if request.url.path.startswith(k)),
300
)
return Response(
status_code=204,
headers={
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": str(max_age),
"Vary": "Origin",
},
)
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
return response
Edge-case and security boundaries
Subdomain origins and cache key isolation
Each unique Origin header value produces a separate preflight cache entry. https://app.example.com and https://beta.example.com are distinct origins: they never share cached preflight state, even when both are served by the same API. This matters for dynamic origin validation patterns — you must reflect the exact requesting origin in Access-Control-Allow-Origin per entry.
Null-origin and sandboxed iframes
Sandboxed <iframe> elements without allow-same-origin send Origin: null. Browsers do not cache preflight responses for null origins — each cross-origin request from a sandboxed frame triggers a fresh OPTIONS round-trip regardless of Access-Control-Max-Age. Do not attempt to serve Access-Control-Allow-Origin: null in production; it grants access to any sandboxed document.
Opaque responses and Access-Control-Allow-Credentials
When Access-Control-Allow-Credentials: true is present, Access-Control-Allow-Origin must reflect the exact requesting origin rather than *. The wildcard risks and mitigation page covers why wildcards are rejected for credentialed flows. Credentialed preflight cache entries also scope to the exact origin, so the cache key effectively includes credential mode.
Access-Control-Max-Age: 0 semantics
A value of 0 instructs the browser to never cache the preflight response. This is sometimes used in zero-trust architectures that require server-side auth on every OPTIONS request, but it doubles the request count for every cross-origin call. Use it only when the security requirement justifies the latency cost.
Header order, casing, and what “sorted” really means
The header component of the cache key is a normalised list: the browser lowercases every name and sorts them before comparing, so Content-Type, Authorization and authorization, content-type resolve to the same entry. What does not normalise is the set itself. A client library that conditionally attaches X-Request-ID or a tracing header such as traceparent produces two distinct key variants for the same logical call, and each pays its own preflight. Auditing this is the single highest-value optimisation on most APIs: pin the request-header set in one place in the client, send the same list on every call to a given route, and a nominal 600-second TTL starts behaving like one.
What evicts an entry before the TTL expires
TTL expiry is only one of several ways an entry disappears. Chromium flushes the whole preflight cache when the network state changes — switching between Wi-Fi and cellular, or a VPN connecting — and again when the user clears site data or the renderer process for that site is discarded under memory pressure. Private/incognito windows keep an entirely separate store that is destroyed with the session. Combined with the partitioning described above, this means you should size TTLs to reduce steady-state preflight volume, never to make correctness depend on an entry still being present.
The preflight cache is not the HTTP cache
Access-Control-Max-Age never influences whether the actual response is cached, and Cache-Control on an OPTIONS response never influences the browser’s preflight store. They are two disjoint mechanisms that happen to sit on the same request. Sending Cache-Control: no-store on a preflight does not stop the browser reusing the preflight for the next ten minutes, and sending a long Access-Control-Max-Age on a GET response does nothing at all. Both statements matter when you write a cache-invalidation runbook: purging the CDN does not shorten anybody’s browser-side preflight entry.
Rolling out a new required request header
The most common way a long TTL causes an outage is a deploy that starts sending a new header from the frontend before the backend advertises it. For up to ten minutes, browsers holding a stored entry will apply the old Access-Control-Allow-Headers list, see that the new header is not covered, and block the request without ever asking the server again. Sequence the rollout instead: ship the server change that adds the header to Access-Control-Allow-Headers first, wait longer than the configured TTL, and only then ship the frontend change that starts sending it. If you cannot wait, change the request URL — a new API version prefix or a distinct path — so the cache key differs and no stored entry applies.
Proxy / CDN interaction
CDN and reverse-proxy layers are invisible to browser preflight caching. They evaluate Cache-Control and Vary — not Access-Control-Max-Age. This creates two independent caches that must be kept aligned.
The stale CDN preflight problem
If a CDN caches an OPTIONS response for longer than Access-Control-Max-Age, clients receiving a new browser instance (cold cache) get the CDN’s stale CORS headers. If those headers no longer match the current policy (e.g., after adding a new allowed header), the browser’s preflight check fails even though the resource is reachable.
The correct CDN configuration passes OPTIONS requests directly to origin:
# Nginx as CDN/reverse-proxy layer: bypass cache for OPTIONS
location /api/ {
if ($request_method = OPTIONS) {
# No proxy_cache here — always pass through to origin
proxy_no_cache 1;
proxy_cache_bypass 1;
proxy_pass http://origin_backend;
}
# Cache actual responses normally
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_pass http://origin_backend;
}
CDN Vary: Origin requirement
Provider defaults differ enough to be worth checking individually. Cloudflare does not cache OPTIONS by default and honours Vary only for a small set of headers, so an Origin-varying preflight generally has to be answered in a Worker or a transform rule rather than left to the cache. CloudFront ignores Vary entirely for cache-key purposes and requires Origin to be listed explicitly in the cache policy’s header allowlist; forget that and the first origin to warm an edge location wins for every other origin routed through it. Fastly gives you full control in VCL, which also means the default behaviour is whatever your service was configured with — check that vcl_recv does not normalise Origin away before the hash is computed.
If a CDN does cache OPTIONS responses (e.g., for high-volume pre-warming), it must vary by Origin. Without Vary: Origin, the CDN returns the same cached Access-Control-Allow-Origin value regardless of which origin requested it. A response cached for https://app.example.com served to https://beta.example.com triggers a CORS failure. Always set Vary: Origin on preflight responses; this aligns with the header deduplication techniques that prevent CORS header conflicts across caching layers.
The preflight cache flow
DevTools + curl verification checklist
curl -si -X OPTIONS https://api.example.com/resource \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization" \ | grep -i "access-control-max-age\|vary\|access-control-allow-origin"const cachedPreflights = performance.getEntriesByType('resource') .filter(e => e.initiatorType === 'fetch' && e.transferSize === 0); console.log(`Warm preflight cache hits: ${cachedPreflights.length}`);
Common mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Setting max-age: 0 or omitting the header on stable routes |
Browser issues a fresh OPTIONS request before every cross-origin call, doubling request count and adding 50–200 ms per call |
Use minimum 300 for dynamic routes; 600 for stable routes |
Setting max-age above 600 s expecting Chrome benefit |
Chrome and Safari silently clamp to 600 s; excess values are discarded without error | Cap server directives at 600 for cross-browser consistency |
Omitting Vary: Origin on preflight responses |
CDN or shared caches return cached Access-Control-Allow-Origin for wrong origins, triggering 403 or CORS errors |
Always attach Vary: Origin to every OPTIONS response |
| Hardcoding 600 s on volatile endpoints | After adding a new required request header, existing browser caches serve stale policy for up to 10 minutes, blocking valid requests | Use dynamic TTL middleware; cap volatile routes at 60–120 s |
Duplicating Access-Control-Max-Age via multiple middleware layers |
Conflicting values produce undefined behavior; some browsers use the first, some the last | Audit the full middleware chain; enforce single-point header injection |
| Relying on CDN to cache OPTIONS with long TTL | CDN serves stale CORS headers after policy changes; new headers or methods are blocked until CDN cache expires | Configure CDN to bypass cache for OPTIONS method; let origin serve all preflights |
FAQ
What is the maximum Access-Control-Max-Age browsers will honor?
Chrome and Safari cap at 600 seconds (10 minutes); Firefox caps at 86 400 seconds (24 hours). Values above these ceilings are silently clamped to the engine-specific maximum. There is no browser warning or error when truncation occurs.
Does clearing browser cache invalidate preflight cache entries?
Yes. Standard “Clear browsing data” operations, incognito mode, and programmatic caches.delete() calls all evict stored preflight responses, regardless of the remaining max-age TTL. This is useful for testing but cannot be triggered programmatically for end users.
Can CDNs override Access-Control-Max-Age?
CDNs ignore Access-Control-Max-Age entirely and cache based on Cache-Control and Vary. A CDN holding a stale OPTIONS response can serve incorrect CORS headers to cold-cache browsers independently of the browser preflight cache. Configure CDN pass-through for OPTIONS to prevent this.
How do I force a preflight cache refresh without user intervention?
Change the request URL (introduce API versioning), modify the Access-Control-Allow-Methods or Access-Control-Allow-Headers values, or use a cache-busting query parameter. Any change to the cache key tuple — (origin, URL, method, headers) — forces a fresh OPTIONS round-trip.
Why does Access-Control-Max-Age have no effect on simple requests?
Simple requests (same-origin-safe method, no custom headers, no credentials) never trigger a preflight, so there is no preflight response to cache. Access-Control-Max-Age is silently ignored on responses to actual GET or same-origin-safe POST requests.
Does the preflight cache survive a reload, a new tab, or a service worker fetch?
A reload and a new tab both keep the entry, because the store belongs to the browser profile and network partition rather than to the page. A service worker does not get its own copy but its fetches are still subject to the same store, so a request the worker makes on behalf of a page can hit an entry the page created. What does not survive is a private window, a network change such as switching to a VPN, or clearing site data.
Should I lower max-age before deploying a change to the allowed headers?
Lowering it in advance helps only if the lower value has already propagated, which takes at least one old TTL to happen. The reliable sequence is to ship the server change that widens Access-Control-Allow-Headers first, wait longer than the current TTL, and only then ship the client change that starts sending the new header. If the deploy cannot wait, change the request URL so the cache key differs and no stored entry applies.