Caching Preflight Responses
Every preflight request is an HTTP round-trip that does zero useful work from the user's perspective. It carries no payload. It returns no data. It's pure overhead — a permission slip the browser needs before it can do the thing you actually wanted.
On a high-latency connection or a chatty SPA that makes dozens of API calls on page load, preflights add up. A 200ms round trip per preflight, ten API endpoints hit on startup, and you've added two full seconds of latency before a single byte of real data arrives. Your user is staring at a spinner while the browser and your server exchange polite notes about what HTTP methods are acceptable.
The good news: browsers can cache preflight responses, so this tax is mostly a first-visit cost. The bad news: the defaults are surprisingly unhelpful, the caching behavior varies wildly between browsers, and getting it wrong means either unnecessary preflights (slow) or stale cached permissions (broken).
Access-Control-Max-Age: The Basics
The Access-Control-Max-Age response header tells the browser how long, in seconds,
it can cache the result of a preflight request before it needs to ask again.
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: Content-Type, Authorization
Access-Control-Max-Age: 86400
86400 seconds = 24 hours. For the next 24 hours, if the browser needs to make a
request with the same characteristics from the same origin to the same URL, it can
skip the preflight and go directly to the actual request.
Valid values:
| Value | Meaning |
|---|---|
86400 | Cache for 24 hours |
3600 | Cache for 1 hour |
0 | Don't cache — always send a preflight |
-1 | Disable caching (browser-dependent) |
| (absent) | Use the browser's default |
Negative values and zero are useful for debugging. When you're troubleshooting a
CORS issue and the browser keeps using a cached preflight, set Max-Age: 0
temporarily so every request triggers a fresh preflight. Just remember to change
it back.
Browser Maximum Caps
Here's the fun part: every browser imposes its own maximum on Access-Control-Max-Age,
and they all picked different numbers.
| Browser | Maximum Max-Age | Default (when header is absent) |
|---|---|---|
| Chrome / Chromium | 7200 seconds (2 hours) | 5 seconds |
| Firefox | 86400 seconds (24 hours) | 24 hours |
| Safari | 604800 seconds (7 days) | 5 minutes |
Read that table again. If you don't send Access-Control-Max-Age, Chrome will
re-preflight every 5 seconds. Firefox will cache for 24 hours. Safari sits
somewhere in between.
This means:
- Without
Max-Age, Chrome users get hammered with preflight requests on every page load. If your app makes the same API call twice within 5 seconds, the second one skips the preflight. Otherwise, it doesn't. - Without
Max-Age, Firefox users almost never see repeated preflights. Which is great until you change your CORS configuration and Firefox users have stale cached preflights for up to 24 hours.
Always set Access-Control-Max-Age explicitly. Don't rely on browser defaults.
A good production value is 3600 (1 hour) or 7200 (2 hours, Chrome's max). Going
higher than 7200 only benefits Firefox and Safari users; Chrome ignores anything above
its cap.
# Verify the Max-Age header is present
curl -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Content-Type" \
-s -D - -o /dev/null | grep -i "access-control-max-age"
If you get no output, your server isn't sending Max-Age, and Chrome users are paying
the preflight tax on nearly every request.
What Gets Cached: The Preflight Cache Key
The browser doesn't just cache "can I talk to this server" — it caches specific permission grants. The preflight cache entry is keyed on:
- Origin — the requesting origin
- URL — the target URL of the actual request
- Request method — from
Access-Control-Request-Method - Request headers — from
Access-Control-Request-Headers
This means a cached preflight for PUT to https://api.example.com/users from
https://app.example.com with headers Content-Type, Authorization does not
cover:
- A
DELETEto the same URL (different method) - A
PUTtohttps://api.example.com/orders(different URL) - A
PUTto the same URL but with an additionalX-Request-IDheader (different headers) - The same request from
https://other-app.example.com(different origin)
Each unique combination gets its own cache entry and its own expiration timer.
Practical implication: If your SPA hits 15 different API endpoints, each one needs its own preflight on first access. Caching helps with repeated calls to the same endpoint, but it doesn't help with the initial burst.
Cache Invalidation
When does the browser throw away a cached preflight and ask again?
-
The
Max-Ageexpires. Straightforward. -
The user clears browser data. "Clear browsing data" wipes the preflight cache along with everything else.
-
A network error occurs on the actual request. Some browsers invalidate the cached preflight when the actual request fails, reasoning that the server's CORS configuration may have changed.
-
The cached entry doesn't cover the new request. If you start sending a new header that wasn't in the original preflight, the cache doesn't apply, and a new preflight fires.
What does not invalidate the cache:
- Reloading the page (the preflight cache persists across navigations within the same browser session)
- Closing and reopening the tab (usually — depends on browser)
- Server-side changes to CORS configuration (the browser has no way to know)
That last point is important. If you change your allowed origins or methods on the server, users with cached preflights won't pick up the change until their cache expires. This is usually fine for permissive changes (adding a new allowed method) but can be a problem for restrictive changes (removing an allowed origin). A user whose browser has the old preflight cached will continue to make requests that your server now wants to reject, and the browser will happily send them — the actual request always goes through; it's the response that gets blocked.
Performance Impact: Real Numbers
Let's put some numbers on preflight overhead to understand why caching matters.
Assumptions for a typical SPA:
- API server round-trip time: 50ms (same region), 200ms (cross-region)
- Number of unique API endpoints hit on page load: 8
- Number of endpoints requiring preflight: 6 (2 are simple GET requests)
Without preflight caching (Max-Age: 0):
| Scenario | Preflight overhead per page load |
|---|---|
| Same region (50ms RTT) | 6 × 50ms = 300ms |
| Cross-region (200ms RTT) | 6 × 200ms = 1,200ms |
That's 1.2 seconds of pure preflight overhead on every page load for cross-region users. Not on first visit — on every visit.
With preflight caching (Max-Age: 7200):
| Scenario | First visit | Subsequent visits (within 2h) |
|---|---|---|
| Same region | 300ms | 0ms |
| Cross-region | 1,200ms | 0ms |
Preflight caching eliminates the overhead entirely on return visits. For SPAs where users stay on the page and make repeated API calls, the first batch of preflights is the only one that matters.
Measuring in practice:
Open Chrome DevTools, Network tab, and filter by method:OPTIONS. Look at the
Timing tab for each preflight. You'll see:
- Stalled/Queueing: Time waiting to be sent
- DNS Lookup: Usually cached
- Initial Connection/SSL: Usually reused
- Waiting (TTFB): Server processing time — this is the meat of it
- Content Download: Negligible (preflights have no body)
For a cached preflight, you won't see an OPTIONS request at all. That's how you
know caching is working — the preflight simply doesn't appear in the Network tab.
Strategy: Minimizing Preflights in Production
1. Set a generous Max-Age
Access-Control-Max-Age: 7200
7200 seconds (2 hours) is the maximum Chrome will honor. There's no benefit to going
higher for Chrome users, but Firefox and Safari users get the longer duration. Use
86400 if you want to maximize caching across all browsers.
2. Design APIs that avoid preflights where possible
Remember what triggers a preflight:
- Non-simple methods (
PUT,DELETE,PATCH) - Non-simple headers (
Authorization, custom headers) - Non-simple
Content-Type(anything other thanapplication/x-www-form-urlencoded,multipart/form-data,text/plain)
Some teams deliberately design their APIs to avoid preflights for the most common operations:
# This triggers a preflight (non-simple method)
DELETE /api/sessions/current
# This avoids a preflight (POST is simple, form content type is simple)
POST /api/logout
Content-Type: application/x-www-form-urlencoded
I'm not saying you should contort your API design around CORS rules. But if you have a high-traffic endpoint where every millisecond matters, knowing which requests are "simple" lets you make informed tradeoffs.
The big one: Content-Type. If you could use
Content-Type: application/x-www-form-urlencoded instead of application/json,
your POST requests would be simple requests — no preflight. Some teams use this
for lightweight endpoints. Most teams decide that application/json is worth the
preflight. It's a judgment call.
3. Batch API calls
Instead of 10 separate API calls on page load (potentially 10 preflights), consider a single batch endpoint:
POST /api/batch
Content-Type: application/json
[
{"method": "GET", "path": "/users/me"},
{"method": "GET", "path": "/notifications"},
{"method": "GET", "path": "/settings"}
]
One preflight instead of many. The tradeoff is API design complexity.
4. Use a same-origin proxy
If your frontend and API are on different origins, put a reverse proxy in front of them so they share an origin:
https://app.example.com/ → frontend server
https://app.example.com/api/ → proxied to API server
No cross-origin requests, no CORS, no preflights. This is often the simplest solution, though it requires infrastructure changes.
5. Use GET where semantically appropriate
GET requests without custom headers are simple requests — no preflight. If you're
fetching data (not mutating state), GET is probably the right method anyway, and
it avoids the preflight entirely.
CDN and Proxy Caching Considerations
The preflight cache we've discussed so far is the browser's cache. But there's another caching layer that matters: CDNs and reverse proxies sitting between the browser and your server.
Can CDNs cache OPTIONS responses?
Yes. An OPTIONS response is just an HTTP response, and CDNs can cache it like any
other. Whether they should depends on your setup.
If your Access-Control-Allow-Origin is always * (static):
CDN caching is safe. The OPTIONS response is the same regardless of who's asking.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 7200
Cache-Control: max-age=7200
Adding Cache-Control tells the CDN to cache the OPTIONS response. This offloads
preflight handling from your origin server entirely — the CDN handles it at the edge,
which can shave off significant latency for geographically distributed users.
If your Access-Control-Allow-Origin is dynamic (varies by origin):
You must include Vary: Origin on the 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: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Cache-Control: max-age=7200
Without Vary: Origin, the CDN will serve the first cached OPTIONS response to all
origins, regardless of whether they match. This leads to the intermittent-failure
nightmare described in Chapter 7.
CDN configuration pitfalls
CloudFront: By default, CloudFront does not forward the Origin header to your
origin server, which means your server can't do dynamic origin matching, and all
responses get the same cached version. You need to configure CloudFront to include
Origin in the cache key (under "Cache Key and Origin Requests" → "Headers").
# AWS CLI: Add Origin to the cache policy
aws cloudfront create-cache-policy \
--cache-policy-config '{
"Name": "CORS-Aware",
"ParametersInCacheKeyAndForwardedToOrigin": {
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": { "Items": ["Origin"], "Quantity": 1 }
}
}
}'
Cloudflare: Cloudflare passes the Origin header through by default and respects
Vary: Origin in most configurations. But if you're using Cloudflare Workers or
custom caching rules, double-check that OPTIONS responses are being cached with the
origin in the cache key.
Nginx as a reverse proxy: If Nginx sits in front of your app server and caches
responses, make sure it handles OPTIONS properly:
# Pass OPTIONS through to the backend, don't handle them in Nginx
# unless you're configuring CORS at the Nginx level
proxy_cache_key "$scheme$request_method$host$request_uri$http_origin";
Including $http_origin in the cache key is the Nginx equivalent of Vary: Origin.
Browser DevTools: Seeing Cached vs. Fresh Preflights
Knowing whether a preflight was cached or fresh is essential for debugging. Here's how to tell in each browser.
Chrome DevTools
- Open the Network tab.
- Make the cross-origin request.
- Look for
OPTIONSrequests in the list.
If you see an OPTIONS request: The preflight was NOT cached. It was a fresh round
trip to the server. Check the Response Headers for Access-Control-Max-Age to
understand why it might not be caching (missing header, expired, or Max-Age: 0).
If you DON'T see an OPTIONS request: The preflight WAS cached. The browser used its cached result and skipped the network request entirely.
Forcing a fresh preflight:
To clear the preflight cache without clearing all browser data, you have a few options:
- Open DevTools, right-click the reload button, select "Empty Cache and Hard Reload." This clears the HTTP cache but may not clear the preflight cache (they're separate).
- The reliable way: Open
chrome://net-internals/#events, or simply open an Incognito window. Incognito starts with an empty preflight cache. - Set
Access-Control-Max-Age: 0on your server temporarily.
Firefox DevTools
Firefox's Network tab works similarly. OPTIONS requests appear when preflights are
sent. Absent OPTIONS means cached.
Firefox also has a useful Network tab feature: the Transferred column shows "cached" for responses served from cache. This works for regular responses but cached preflights simply don't appear at all.
Safari DevTools
Safari's Web Inspector shows OPTIONS requests in the Network tab. Safari has the
most conservative default cache behavior among the three (5 minutes), so you'll see
preflights more frequently in Safari during testing.
Debugging Checklist
When preflight caching isn't working as expected:
1. Is Access-Control-Max-Age present in the OPTIONS response?
curl -X OPTIONS <url> -H "Origin: ..." \
-H "Access-Control-Request-Method: PUT" -v
2. What value is Max-Age set to?
- 0 or negative → caching is intentionally disabled
- < 5 → effectively no caching in Chrome
- > 7200 → Chrome will clamp to 7200
3. Is the cache key changing?
- Different URL → different cache entry
- Different request headers → different cache entry
- Are you adding dynamic headers (timestamps, nonces)?
4. Is a CDN stripping the header?
- Compare curl-to-origin vs curl-through-CDN
- curl https://api.example.com/data (direct) vs
curl https://cdn.example.com/data (via CDN)
5. Is the browser in a state that ignores the cache?
- Incognito mode: starts fresh each time
- DevTools "Disable cache" checkbox: also disables preflight cache
- Some extensions interfere with caching
That DevTools "Disable cache" checkbox is a surprisingly common gotcha. You're debugging why preflights aren't being cached, you've been staring at it for an hour, and then you notice the checkbox that you enabled three days ago and forgot about. Not that I'm speaking from experience.
A Complete Optimized Setup
Here's what a well-optimized production CORS configuration looks like from a caching perspective:
Server response to OPTIONS:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Cache-Control: public, max-age=7200
Server response to actual requests:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining
Vary: Origin
Content-Type: application/json
What this achieves:
- Browser caches preflights for up to 2 hours (Chrome's max)
- CDN can cache
OPTIONSresponses, keyed by origin (Vary: Origin) - Actual responses include the minimum necessary CORS headers
Expose-Headerslets JavaScript read operational headers- Credentials are properly supported with specific origin (no wildcard)
Express implementation:
app.options("*", (req, res) => {
const origin = req.headers.origin;
if (allowedOrigins.has(origin)) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID");
res.set("Access-Control-Allow-Credentials", "true");
res.set("Access-Control-Max-Age", "7200");
res.set("Vary", "Origin");
res.set("Cache-Control", "public, max-age=7200");
}
res.status(204).end();
});
This is the kind of setup where you configure it once, set the Max-Age, and then
forget about preflights. Your users get one round of preflights on their first visit,
the browser caches them, and subsequent interactions are fast. CDN caching means even
the first-visit preflights are answered from the edge.
That's the goal: make preflights invisible. They should be a one-time cost that users never notice, not a per-request tax that degrades your application's responsiveness.