Preflight Request Optimization & Caching Strategies
The WHATWG Fetch Standard (§4.8–4.10) defines a preflight as a synthetic OPTIONS request the browser dispatches before any cross-origin request that falls outside the simple request classification. Eliminating or caching that round trip requires precise alignment between server response headers, browser-enforced TTL limits, and infrastructure routing — all three layers must cooperate for optimization to hold.
Key Architectural Principles
Access-Control-Max-Ageis a browser-side directive; the server proposes a TTL but each engine enforces its own hard cap, silently overriding higher values.- The preflight cache is keyed on
(serialized URL, method, sorted request-header names)— a single extra header in one request breaks the cache hit for all subsequent requests with the same URL and method. - Credential-bearing requests (
credentials: "include") require the server to reflect the exactOriginvalue;*is forbidden by spec and causes an immediate browser rejection. Vary: Originis mandatory on any resource whose CORS headers differ by origin — missing it causes CDNs to serve a cached response to an unintended origin.- HTTP/2 and HTTP/3 reduce preflight cost through multiplexing, but do not eliminate the cache-miss round trip; only correct
Access-Control-Max-Ageconfiguration does that. - Eliminating preflights via a reverse proxy is architecturally safe: the browser never sees a cross-origin boundary, so neither CORS nor the Same-Origin Policy applies.
OPTIONShandlers must return zero-body204 No Contentresponses; any middleware that reads session state or writes to a database on the OPTIONS path adds latency with no security benefit.
Preflight Flow Diagram
Core Mechanics: How the Browser Enforces Preflight
The WHATWG Fetch algorithm evaluates each cross-origin request against a simple request classification matrix before deciding whether to dispatch an OPTIONS request. The evaluation is purely client-side: the server has no say in whether a preflight is triggered.
After a successful preflight, the browser stores the response in a per-partition CORS cache. The cache entry records the allowed methods and headers for the exact (URL, method, header set) tuple. On subsequent requests that match that tuple, the browser skips the OPTIONS call entirely — provided the entry has not expired or been invalidated by a credential change.
Cache invalidation happens in four scenarios:
Access-Control-Max-Ageexpires.- The request adds a header not listed in the cached
Access-Control-Allow-Headersvalue. - The request uses a method not listed in the cached
Access-Control-Allow-Methodsvalue. - The user’s credential state changes (login/logout), which the browser treats as a new trust context.
Understanding these rules is the foundation for all optimization work covered on this page.
Request Classification Matrix
| Scenario | HTTP Method | Headers | Content-Type | credentials | Preflight? |
|---|---|---|---|---|---|
| Static asset fetch | GET |
None | — | omit |
No |
| Form POST | POST |
Content-Type only |
application/x-www-form-urlencoded |
omit |
No |
| JSON API call | POST |
Content-Type |
application/json |
omit |
Yes |
| Authenticated API | GET |
Authorization |
— | include |
Yes |
| REST mutation | PUT, DELETE, PATCH |
Any | Any | Any | Yes |
| Custom header | Any | X-Request-ID or similar |
Any | Any | Yes |
The Content-Type: application/json case is the most common production surprise. Many teams assume a POST without Authorization will avoid a preflight — it will not, because application/json is not in the allowed Content-Type set for simple requests.
Cache Duration Tuning & Max-Age Configuration
The cache duration tuning workflow begins with a single fact: Access-Control-Max-Age is advisory. Each browser engine enforces a hard cap it will not exceed, regardless of the server’s value.
| Browser engine | Hard TTL cap | Default when header absent |
|---|---|---|
| Blink (Chrome, Edge) | 600 s | 5 s |
| Gecko (Firefox) | 86 400 s (24 h) | 5 s |
| WebKit (Safari) | 600 s | implementation-defined |
Setting Access-Control-Max-Age: 600 gives consistent behaviour across all production browsers. Values above 600 benefit Firefox users only; Chrome and Safari silently ignore the excess.
When the header is absent entirely, Blink and Gecko default to 5 seconds — meaning a user refreshing a page every 10 seconds triggers a preflight on every load. Even a modest Access-Control-Max-Age: 60 reduces preflight frequency by 12× in that scenario.
Canonical OPTIONS response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
Access-Control-Max-Age: 600
Vary: Origin
The Vary: Origin header is not part of the preflight cache mechanism itself, but it is mandatory on the same-origin resource response to prevent CDNs from serving a cached CORS-permissive response to a same-origin request (or vice versa).
Header Deduplication & Cache Key Management
Because the preflight cache key includes the sorted set of request header names, every unique header combination in your codebase generates a distinct cache entry. A client that sometimes sends X-Request-ID and sometimes omits it will cache-miss on every other preflight even if the URL and method are identical.
Header deduplication techniques address this at the API client layer:
- Consolidate authentication metadata into a single
Authorization: Bearer <token>header rather than splitting tokens acrossX-Auth-Token,X-User-ID, andX-Session-ID. - Remove legacy cross-origin headers (
X-Requested-With,X-CSRF-Token) from fetch calls unless a specific backend middleware requires them. - Standardize client interceptors to emit a deterministic header set per API domain — intercept at the
fetchwrapper or Axios instance level, not per-component.
The Vary header on the resource response (not the preflight) affects CDN caching separately. Overly broad Vary values (e.g., Vary: Accept-Encoding, Origin, Accept-Language, Authorization) fragment CDN cache storage geometrically. Audit Vary values on your API gateway and reduce them to the minimum set required for correctness.
Server-Side OPTIONS Endpoint Architecture
An OPTIONS handler has one job: return CORS authorization metadata as fast as possible. Any middleware that queries a database, reads session storage, or renders a template on the OPTIONS path is wasted compute.
Express.js — zero-overhead OPTIONS handler:
// Register OPTIONS handler before any auth middleware
app.options('/api/*', (req, res) => {
const allowed = ['https://app.example.com', 'https://staging.example.com'];
const origin = req.headers.origin;
if (allowed.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Request-ID');
res.set('Access-Control-Max-Age', '600');
res.set('Vary', 'Origin');
}
res.status(204).end();
});
// Auth middleware applies only to non-OPTIONS routes
app.use('/api/*', requireAuth);
Nginx — edge-level OPTIONS interception:
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-ID" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://backend;
}
The always flag ensures these headers are appended even if the upstream returns a non-2xx status. Without always, a backend error on a non-OPTIONS request strips the CORS headers and the browser raises a network error that masks the real failure.
For the full OPTIONS endpoint design reference, including rate-limiting patterns and connection-pooling configuration, see the dedicated section.
Fetch API — confirming the preflight is skipped:
// After the first successful preflight (max-age: 600),
// subsequent requests skip the OPTIONS round trip for 10 minutes
const res = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
credentials: 'include',
});
Security Boundary Mapping
Optimization must operate within the constraints of the Same-Origin Policy. Relaxing CORS headers beyond what the spec permits introduces data exfiltration vectors, not just performance gains.
Credential isolation rules
When credentials: "include" is set, the WHATWG Fetch spec (§3.2.3) requires:
Access-Control-Allow-Originmust reflect the exactOriginrequest header value —*is forbidden.Access-Control-Allow-Credentials: truemust be present.- The origin reflected must be one the server has explicitly validated against its allowlist. Echoing the
Originheader without validation introduces a universal CORS bypass.
Wildcard prohibitions
Access-Control-Allow-Origin: * is legitimate for truly public, non-credentialed resources (public CDN assets, open APIs). It is inappropriate for any endpoint that reads session cookies, issues tokens, or returns user-scoped data — even if the endpoint does not currently require credentials: "include", because a future caller might.
Vary requirements
Per dynamic origin validation patterns, whenever the server conditionally reflects the Origin header, the resource response must include Vary: Origin. Omitting Vary: Origin when using dynamic origin reflection causes CDN nodes to cache the first response and serve it to all subsequent requesters, regardless of their origin — a cross-tenant data leak.
Audit trail for proxy bypass
Every proxy rewrite or edge rule that eliminates preflights must be version-controlled. Infrastructure drift — a proxy rule removed during a platform migration — silently re-exposes the API to the full CORS flow, which may break clients that never implemented preflight handling.
Infrastructure Interaction: CDN, WAF, and Reverse Proxy
Preflight caching interacts with infrastructure layers in ways that are not always obvious from the CORS spec alone.
Reverse Proxy Bypass
Routing the frontend and API through the same origin eliminates preflights entirely. The browser never initiates a cross-origin request, so the Same-Origin Policy never applies.
Proxy bypass strategies cover three patterns:
| Strategy | How it works | Credential support | TLS complexity |
|---|---|---|---|
Nginx path rewrite (/api/*) |
Proxy frontend and API on same domain/port | Full | None |
DNS CNAME / ALIAS (api.example.com) |
Same domain, different subdomain | Requires sameSite=None cookies |
Wildcard cert required |
| Edge Worker rewrite | Cloudflare/Vercel/Fastly rewrites at the PoP | Full | Managed |
Subdomains are not same-origin. app.example.com and api.example.com are distinct origins under the WHATWG origin tuple definition — a reverse proxy on app.example.com/api/* is required to collapse them.
CDN Preflight Caching
CDNs can cache OPTIONS responses safely when:
- Cache keys include
Origin(often configured as a custom cache key header). Vary: Originis present on the cached response.- The CDN’s cache TTL is set to match or be less than
Access-Control-Max-Age.
Without condition 2, a CDN caches the preflight response for Origin: https://app.example.com and serves it to Origin: https://attacker.example.com. The attacker’s browser then believes the server has authorized its origin.
WAF Interaction
Web Application Firewalls that rate-limit by IP can incorrectly throttle OPTIONS requests from CDN edge nodes, because all preflight requests from a PoP share the same egress IP. Configure WAF rules to whitelist OPTIONS requests from known CDN CIDR ranges, or key rate limiting on the Origin header rather than the source IP.
HTTP/2 and HTTP/3 reduce per-connection overhead for remaining preflights through header compression (HPACK/QPACK) and stream multiplexing — but they do not affect whether the browser sends an OPTIONS request. The cache miss path still costs one full round trip before the actual request.
Debugging Workflow: DevTools + curl Trace
Step 1 — Capture the preflight in DevTools
- Open Chrome DevTools → Network tab.
- Enable Preserve log and Disable cache.
- Trigger the cross-origin request.
- Filter by Method: OPTIONS — each row is a preflight.
- Select a row → Headers tab → verify
Access-Control-Max-Ageis present in the response. - Select the same row → Timing tab → check for
(preflight)in the initiator chain on subsequent requests. If the initiator row shows(from preflight cache), the cache is working.
Step 2 — Confirm the cache hit
Reload the page without disabling the cache. The OPTIONS row should disappear from the Network panel for all requests within the TTL window. If it reappears, the cache key has changed — compare the Access-Control-Request-Headers value between the two captures.
Step 3 — curl probe for the OPTIONS response
curl -si -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization, Content-Type" \
https://api.example.com/data
Expected output:
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, DELETE
access-control-allow-headers: Authorization, Content-Type, X-Request-ID
access-control-max-age: 600
vary: Origin
Error code → cause mapping
| Observed symptom | Root cause | Fix |
|---|---|---|
OPTIONS appears on every request despite Max-Age |
Header set is non-deterministic — a new header name appears in some requests | Audit and standardize client interceptors |
Browser console: has been blocked by CORS policy: Response to preflight request doesn't pass |
Server returns non-2xx on OPTIONS, or missing Access-Control-Allow-Origin |
Add dedicated OPTIONS handler before auth middleware |
Access-Control-Allow-Origin header missing on error responses |
always flag missing in Nginx add_header directive |
Add always to all add_header lines in the OPTIONS block |
| CDN serves stale preflight to wrong origin | Vary: Origin absent on OPTIONS response |
Add Vary: Origin to all OPTIONS responses; purge CDN cache |
| Preflight succeeds but actual request blocked | Access-Control-Allow-Headers in preflight does not match actual request headers |
Ensure the list covers every header the client sends, including Content-Type |
Common Implementation Mistakes
| Issue | Technical impact | Remediation |
|---|---|---|
Access-Control-Max-Age set to 86400 expecting Chrome to cache for 24 h |
Chrome silently caps at 600 s; cache-miss rate is identical to setting 600 s, but intent is obscured | Set 600 s explicitly to document the cross-browser limit |
Access-Control-Allow-Origin: * on a credentialed endpoint |
Immediate browser rejection; preflight fails at the network layer before the actual request is sent | Reflect the exact request Origin after allowlist validation |
Missing Vary: Origin on API responses |
CDN serves origin-A’s cached response (with permissive CORS headers) to origin-B | Add Vary: Origin to every response whose Access-Control-Allow-Origin is dynamic |
| Auth middleware executes before OPTIONS handler | Auth middleware returns 401, stripping CORS headers; browser sees a network error | Register OPTIONS routes before auth middleware in the Express/Koa/Fastify pipeline |
Echoing Origin without allowlist validation |
Any origin can get a permissive preflight response — universal CORS bypass | Validate Origin against a server-side allowlist before reflecting it |
Access-Control-Allow-Headers omits Content-Type |
Preflight succeeds for Authorization but fails for the actual application/json body |
Include Content-Type in Access-Control-Allow-Headers whenever the client sends JSON |
FAQ
Why do preflight requests still occur despite Access-Control-Max-Age being set?
Browsers enforce strict TTL caps (Chrome/Edge/Safari: 600 s, Firefox: 86 400 s). Beyond the cap, a credential state change, a new header name in the request, or a Vary mismatch invalidates the cached entry and forces a fresh OPTIONS round trip. Capture two consecutive Network traces and diff the Access-Control-Request-Headers values — any difference in the sorted header list produces a cache miss.
Can CDNs safely cache CORS preflight responses?
Yes, when configured with Vary: Origin, an origin allowlist, and cache keys that include the Origin and Access-Control-Request-Headers values. Without Vary: Origin, a CDN caches the first preflight response and serves it to every subsequent origin, regardless of whether that origin is authorized — creating a cross-tenant authorization leak.
Does eliminating preflights via a reverse proxy break credential-bearing requests?
No. A reverse proxy makes the browser treat the API as same-origin. The Same-Origin Policy never triggers CORS, so cookies and Authorization headers flow without any preflight negotiation. The trade-off is infrastructure complexity: you must manage TLS certificates for the consolidated domain and ensure the proxy is in the trusted path for all clients.
What is the safest Access-Control-Max-Age value for cross-browser consistency?
600 seconds. Chrome, Edge, and Safari cap at 600 s regardless of the server’s value; Firefox honours up to 86 400 s. Setting 600 s is honest, cross-browser consistent, and avoids confusion when debugging cache behaviour.
Why does Access-Control-Allow-Origin: * fail when credentials are included?
The WHATWG Fetch specification (§3.2.3) forbids credential-bearing cross-origin responses from using a wildcard Allow-Origin. The browser rejects the response at the network layer — the failure occurs before any JavaScript error handler can inspect it. You must reflect the exact request Origin after validating it against a server-side allowlist. See wildcard risks and mitigation strategies for a full treatment.
Related
Topics in This Section
Header Deduplication Techniques for CORS Preflight Optimization
How redundant Access-Control-* headers fragment browser preflight caches and inflate cross-origin latency — with deterministic deduplication strategies and annotated config examples for Nginx, Express, and Envoy.
OPTIONS Endpoint Design for CORS Preflights
Architect HTTP OPTIONS endpoints that handle CORS preflights with minimal overhead: stateless routing, validated header allowlists, 204 responses, and cache-aware headers that keep browsers out of the preflight loop.
Cache Duration Tuning & Max-Age
Technical breakdown of Access-Control-Max-Age mechanics, browser-imposed cache limits, and step-by-step configuration for Nginx and Node.js to eliminate redundant OPTIONS round-trips.
Proxy Bypass Strategies for CORS Preflight
How to configure reverse proxies, CDNs, and API gateways to terminate CORS preflight OPTIONS requests at the edge — preventing backend routing failures, stripping, and 4xx responses.