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.
Three further properties of the cache surprise teams during rollout. First, the cache is partitioned: modern engines key it by the top-level site as well as the requesting origin, so a preflight warmed while your app is embedded in a partner’s page does not serve the standalone app, and vice versa. Second, the entry records the credentials mode used when it was created — a grant obtained by a credentials: "omit" call is not reused by a later credentials: "include" call to the identical URL, because the two negotiate different response requirements. Third, there is no purge API. Nothing you can send from the server evicts a stored grant; the only levers are letting the TTL run out, changing the request URL, or having the user clear browsing data. That asymmetry is the reason a long Access-Control-Max-Age is a commitment rather than a setting.
A failed preflight is also not cached. If the OPTIONS response is a 4xx/5xx, omits Access-Control-Allow-Origin, or fails the origin comparison, the browser discards the result and repeats the OPTIONS on the next attempt. This is helpful — a bad deploy heals the moment you fix it — but it makes an outage noisy, because every retry from every tab pays a fresh round trip against an endpoint that is already failing.
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).
Two details in that response block behave differently from how they read. Access-Control-Max-Age: 0 is a legitimate value and means “do not cache” — the browser repeats the OPTIONS on every call, which is the correct setting for an endpoint whose method or header policy changes per request. A negative or non-numeric value is not an error either; it is simply ignored, and the engine falls back to its 5-second default, so a templating bug that emits Access-Control-Max-Age: verbatim degrades silently rather than failing loudly. Separately, the wildcard in Access-Control-Allow-Methods: * and Access-Control-Allow-Headers: * only expands when the request is not credentialed. On a credentials: "include" request the asterisk is matched as a literal header or method name, so a preflight asking for Authorization against a wildcard header list is refused. Credentialed endpoints must enumerate every method and every header explicitly.
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.
Four calls to one endpoint, differing only in whether a tracing header is attached, produce two independent cache entries and two OPTIONS round trips:
The tuple the browser hashes is built from the Access-Control-Request-Headers value it would send: header names are lowercased, deduplicated, sorted lexicographically, and joined with commas. That normalisation is worth knowing, because it means casing and ordering differences in your client code are free — X-Request-Id and x-request-id collapse to the same entry — while presence differences are not. It also means a header your framework adds conditionally (a build hash, a feature-flag cohort, a locale) is far more expensive than a header it always adds, even when the conditional one is smaller.
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.
Collapsing them changes what the browser is even asked to decide, which is why the saving is a whole round trip rather than a faster one:
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.
Private Network Access preflights
A second, separate preflight exists that no amount of CORS tuning removes. When a page served from a public origin requests a resource on a private or loopback address — a developer machine on http://localhost:8080, a device on 192.168.x.x, a printer or router admin API — Chromium sends an OPTIONS carrying Access-Control-Request-Private-Network: true, and expects Access-Control-Allow-Private-Network: true in the response alongside the usual Access-Control-Allow-Origin. This preflight is issued even for requests that would otherwise be simple, so a plain GET that never preflighted in production suddenly does when a colleague loads the same page against a local device. Teams building hardware companions, local agents, or desktop bridges hit this first in the field rather than in CI, because staging usually runs on a public host. Serving the local endpoint over HTTPS does not exempt it; only returning the private-network header does. Treat it as a distinct capability negotiation that happens to reuse the OPTIONS channel.
Load balancers and header casing
One infrastructure behaviour worth checking during a migration is header rewriting. HTTP/2 requires lowercase field names on the wire, and several load balancers normalise responses on the way through, which is harmless because header matching is case-insensitive. What is not harmless is a layer that merges repeated headers into one comma-joined value. Access-Control-Allow-Origin is a single-value field: two occurrences merged into https://a.example, https://b.example fail the origin comparison even if one of them is the correct answer. If your stack has both an application middleware and an edge rule capable of emitting the header, decide which one owns it and make the other emit nothing at all, rather than relying on one to overwrite the other.
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.
Why does a request to localhost trigger a preflight when the same request to production does not?
Because it is a Private Network Access preflight, not a CORS preflight. When a page served from a public origin targets a loopback or private address, Chromium sends an OPTIONS carrying Access-Control-Request-Private-Network: true and requires Access-Control-Allow-Private-Network: true in the response, alongside the usual origin header. It fires even for requests that would otherwise be simple, so shortening the header set or tuning Access-Control-Max-Age will not remove it — the local endpoint has to answer the private-network negotiation explicitly.
Related
Topics in This Section
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.
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.
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.
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.
Preflight Performance Analysis
Quantify the real latency cost of a CORS preflight OPTIONS round-trip, how Access-Control-Max-Age amortizes it, and HTTP/2 multiplexing and connection-reuse effects.
Browser Preflight Cache Limits
How Blink, Gecko and WebKit cap Access-Control-Max-Age, what each engine puts in its preflight cache key, and what evicts an entry before its TTL ends.