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.

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

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

Browser preflight cache TTL ceilings Bar chart comparing Access-Control-Max-Age ceilings: Chrome and Safari cap at 600 seconds; Firefox caps at 86400 seconds. seconds 86400 600 0 600 s Chrome 600 s Safari 86 400 s Firefox safe max Firefox (log scale)

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.

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

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

Preflight caching sequence diagram Sequence showing a first request triggering an OPTIONS round-trip to origin, the browser caching the max-age value, a second request using the cached entry, and a third request after TTL expiry triggering a fresh OPTIONS call. Browser CDN / Proxy Origin Server Request 1 (cold cache) OPTIONS /api/resource pass-through (no cache) 200 + Max-Age: 600, Vary: Origin preflight response cache stored (TTL 600 s) GET /api/resource (actual) Request 2 (warm cache, within TTL) cache HIT — no OPTIONS GET /api/resource (actual) Request 3 (TTL expired) OPTIONS /api/resource (fresh) cache refreshed GET /api/resource (actual)

DevTools + curl verification checklist

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.