Chrome, Firefox and Safari Preflight Cache Differences
Symptom: the server was corrected twenty minutes ago and Chrome users recovered immediately, but Firefox users are still blocked with a message naming a header the server now allows:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote
resource at https://edge.helioscope.app/v2/reports. (Reason: header 'x-client-version'
is not allowed according to header 'Access-Control-Allow-Headers' from CORS preflight
response).
The same deployment, the same response headers, two completely different outcomes — split cleanly along the browser the user happens to have open.
Root Cause
The three shipping engines implement the same specification with three different implementation-defined maximums, and Access-Control-Max-Age is capped by that maximum without any signal in the response. A server sending 86400 hands Blink a 600-second entry, WebKit a 600-second entry, and Gecko a full 24-hour entry. When the policy in that entry is correct, nobody notices. The moment you widen Access-Control-Allow-Headers — as in the error above, after adding X-Client-Version to the client — the entry becomes a stale grant, and its stored TTL decides how long each engine keeps enforcing yesterday’s answer. Chrome forgets in ten minutes; Firefox may not forget until tomorrow.
This page belongs to Browser Preflight Cache Limits, which covers the ceilings, keys and eviction rules the browser applies to every stored preflight result.
Prerequisite State
- A working CORS configuration: the
OPTIONSresponse already returns a 2xx status with a correctAccess-Control-Allow-Originfor the calling origin. - Access to at least one Chromium-based browser and one Firefox build on the same machine, so you can observe both stores against the same deployment.
- The ability to change the server’s
Access-Control-Max-Ageand redeploy within a few minutes. - A rough breakdown of your real traffic by engine family. Without it, the tiering decision in Step 2 is guesswork.
Step-by-Step Fix
Step 1 — Write down what each engine will actually do
Before touching configuration, fix the facts. These are the properties that differ, and the ones that do not differ are just as important to record — they stop the team from blaming the engine for a key mismatch.
| Property | Blink (Chrome, Edge, Brave) | Gecko (Firefox) | WebKit (Safari, all iOS browsers) |
|---|---|---|---|
| Ceiling on the stored TTL | 600 s | 86 400 s | 600 s |
| Value above the ceiling | clamped, no warning | clamped, no warning | clamped, no warning |
No Access-Control-Max-Age sent |
5 s entry | 5 s entry | 5 s entry |
| Store persisted to disk | no | no | no |
| Partitioned by top-level site | yes | yes | yes |
| Credentials mode part of the key | yes | yes | yes |
| Flushed on network change | yes | yes | yes |
| Typical extra eviction pressure | tab discarding on low memory | container tabs isolate stores | process suspension on iOS |
The three columns agree on everything except one row. That single row is the entire problem, and it is why a fix that “works” is often only a fix for the engine the developer happens to use.
Step 2 — Commit to one TTL policy and write it down once
There are two defensible policies. Pick one explicitly rather than drifting into a number nobody remembers choosing.
Universal. Send 600 to everyone. Every engine stores it in full, nothing is clamped, and the worst-case staleness window after a policy change is ten minutes on every browser. This is the right default for anything with a rollout cadence measured in hours.
# Caddyfile — one value, honoured everywhere
edge.helioscope.app {
@preflight {
method OPTIONS
header Access-Control-Request-Method *
}
handle @preflight {
header {
Access-Control-Allow-Origin "https://dashboard.helioscope.app"
Access-Control-Allow-Methods "GET, POST, PATCH, DELETE"
Access-Control-Allow-Headers "Content-Type, Authorization, X-Client-Version"
Access-Control-Max-Age "600"
Vary "Origin"
}
respond 204
}
reverse_proxy localhost:8080
}
Tiered. Send Gecko a longer value because it will actually keep it. This lowers preflight volume for Firefox users and lengthens the staleness window for exactly those users. Only take it if preflight volume is a measured cost and your policy is stable.
// preflight.go — net/http handler with an engine-aware TTL
package main
import (
"net/http"
"strings"
)
const allowedOrigin = "https://dashboard.helioscope.app"
// Gecko stores up to 86400s; Blink and WebKit clamp at 600s.
func maxAgeFor(ua string) string {
if strings.Contains(ua, "Gecko/") && !strings.Contains(ua, "like Gecko") {
return "86400"
}
return "600"
}
func preflight(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Add("Vary", "Origin")
h.Add("Vary", "User-Agent")
if r.Header.Get("Origin") != allowedOrigin {
w.WriteHeader(http.StatusForbidden)
return
}
h.Set("Access-Control-Allow-Origin", allowedOrigin)
if r.Method != http.MethodOptions {
return
}
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Client-Version")
h.Set("Access-Control-Max-Age", maxAgeFor(r.Header.Get("User-Agent")))
w.WriteHeader(http.StatusNoContent)
}
The like Gecko exclusion is not optional: every Blink and WebKit user agent string contains that token, so a plain Gecko substring test tags Chrome and Safari as Firefox. They clamp the value anyway, so nothing breaks — but the Vary: User-Agent fragmentation you just paid for buys you nothing.
Step 3 — Sequence a widening against the longest ceiling you serve
The error at the top of this page happened because a client change shipped before the server change had outlived every stored entry. The rule that prevents it is simple and engine-independent: widen the server first, wait longer than the longest ceiling in your audience, then ship the client.
For a universal 600 policy that wait is eleven minutes. For a tiered policy that includes Firefox at 86400, it is more than a day. That asymmetry is the real cost of tiering, and it should be weighed against the preflight volume it saves.
Step 4 — When you cannot wait, move the key instead
Stored entries are keyed on the request URL. Changing the URL makes every entry in every engine irrelevant at once, with no waiting and no user action. A version prefix is the cleanest form because it is already in most routing tables.
// client.js — ship the header change and the path change together
const API_BASE = 'https://edge.helioscope.app/v3'; // was /v2
export function fetchReports(range, clientVersion) {
return fetch(`${API_BASE}/reports?range=${encodeURIComponent(range)}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Client-Version': clientVersion,
},
body: JSON.stringify({ range }),
});
}
Keep /v2 serving the old policy until the long-ceiling entries have aged out, then retire it. This is the only technique that recovers Firefox users inside minutes without asking them to clear anything.
Step 5 — Ground the choice in your actual engine mix
Both policies above are only as good as the traffic assumption behind them. Before adopting the tiered variant, count what you serve. A single access-log pass is enough, and it usually settles the argument faster than a design discussion.
# Engine share among requests that carried an Origin header, last 100k lines.
tail -n 100000 /var/log/nginx/access.log \
| grep -o 'Gecko/[0-9]*\|like Gecko\|Version/[0-9.]* Safari' \
| sed -e 's|Gecko/.*|gecko|' -e 's|like Gecko|blink-or-webkit|' -e 's|Version/.* Safari|webkit|' \
| sort | uniq -c | sort -rn
If Gecko is a low single-digit percentage of your traffic, the tiered policy buys almost nothing and costs you a day-long stale window on the users it does reach — take the universal 600. If Gecko is a third of an internal tool’s traffic and the API’s policy has not changed in a year, the tier is worth having. The one situation where the answer is unambiguous is a public API consumed by browsers you do not control: assume the full mix, assume the longest ceiling, and design the rollout around it rather than around the engine on your own laptop.
Whichever you choose, record it next to the value in the configuration. The header is a single integer with no self-documenting behaviour, and the next person to read it will otherwise assume the number was arbitrary and change it.
The decision between waiting and moving the key comes down to two questions:
Verification
curl — confirm what the server is offering, with no engine in the way:
curl -sSi -X OPTIONS https://edge.helioscope.app/v3/reports \
-H 'Origin: https://dashboard.helioscope.app' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type, authorization, x-client-version' \
| grep -i 'HTTP/\|access-control-max-age\|access-control-allow-headers\|vary'
Expected: HTTP/2 204, a single access-control-max-age line with a bare integer, and x-client-version present in access-control-allow-headers.
DevTools per engine — the ceiling is only observable by waiting:
Security Boundary Note
The tiered policy in Step 2 makes Firefox users hold a grant for a day. If the change you are contemplating is a narrowing — removing DELETE from Access-Control-Allow-Methods, or dropping an origin from the allowlist — remember that a preflight response is advice to the browser, not an access control decision. A browser holding a stale entry will happily send the actual request, and only the server-side check on that request stops it. Enforce every policy narrowing at the request handler, and treat the preflight response purely as an optimisation hint. The same principle drives the audit steps in CORS Security Audit Checklist, and the reflection rules in Dynamic Origin Validation Patterns.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Testing a policy widening only in Chrome | The 600 s ceiling hides the problem; Firefox users stay blocked for up to a day on the same deployment | Always confirm the rollout in a Gecko browser as well as a Blink one |
Adopting a tiered TTL without Vary: User-Agent |
A shared cache serves the Firefox-length response to a Chrome user and vice versa, so the tiering is arbitrary | Add User-Agent to Vary, or accept the universal 600 and skip the branch |
Matching the substring Gecko to detect Firefox |
Every Blink and WebKit user agent contains like Gecko, so the branch fires for all three engines |
Test for Gecko/ and explicitly exclude like Gecko |
| Assuming iOS Chrome behaves like desktop Chrome | iOS browsers are all WebKit, so they cap at 600 s and evict on process suspension | Treat any iOS traffic as WebKit traffic in the ceiling table |
FAQ
Which browser should I test a CORS policy change against first?
Test the widening in Chrome, because its short ceiling makes a stale entry expire fastest and confirms the server is correct. Then test the rollout in Firefox, because its long ceiling is the one that decides how long stale grants survive in the field. A change that behaves in both is safe in Safari, whose ceiling matches Chrome — though its extra eviction pressure means you will see more preflights there than the ceiling alone predicts.
Do Chrome and Edge behave identically for preflight caching?
Yes. Edge, Opera, Brave, Arc and Electron all embed Blink and inherit the same 600 second ceiling, the same five second default and the same partitioning rules. Differences between these browsers come from extensions and enterprise policy, not from the preflight cache implementation itself. If one Chromium browser caches a preflight and another does not, look for an extension intercepting the request before you look at the engine.
Is Safari on iPad the same as Safari on macOS for preflight caching?
For the ceiling, yes: both use WebKit and cap at 600 seconds. In practice iOS evicts far more often, because the system suspends and discards web content processes aggressively and mobile networks change frequently. Expect a materially lower hit rate on iOS from the same configuration, and do not read the difference as a bug in your server. Sizing that expectation is easier with the numbers in Preflight vs Simple Request: The Performance Cost.