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

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.

Three engine cards: one row of disagreement Three side-by-side cards describe Blink, Gecko and WebKit. Each card lists the same shared behaviours for defaults, partitioning and eviction, and highlights the single differing value: the ceiling on the stored preflight TTL. Blink Chrome, Edge, Brave, Opera ceiling on stored TTL 600 s default when absent: 5 s memory-only store partitioned by top-level site credentials mode in the key flushed on network change Gecko Firefox, Firefox for Android ceiling on stored TTL 86 400 s default when absent: 5 s memory-only store partitioned by top-level site credentials mode in the key flushed on network change WebKit Safari and every iOS browser ceiling on stored TTL 600 s default when absent: 5 s memory-only store partitioned by top-level site credentials mode in the key process suspension evicts early Five shared rows, one divergent row — the ceiling is the only property worth branching on Everything else that varies between users is key composition, not engine behaviour

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.

Preflight firings and stale windows over one day Three lanes share a twenty-four hour axis. Chrome and Safari show frequent preflight ticks and a short shaded stale window after a policy change, while Firefox shows a single tick and a stale window that spans nearly the whole day. Server sends 86 400 — one active session, ticks mark each OPTIONS actually sent Chrome …one every 600 s for as long as the tab is used Safari same ceiling, plus extra firings after process suspension Firefox one preflight, then nothing for the rest of the day Stale window after a policy widening at hour 0 Chrome and Safari: stale for at most 10 minutes Firefox: stale for up to a full day, still refusing the new header 0 h 6 h 12 h 18 h 24 h

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:

Wait out the ceiling, or move the cache key A decision tree starting from a pending CORS policy change. It branches on whether the change widens or narrows the policy, then on whether the longest ceiling in the audience is an acceptable outage window, ending in three concrete rollout actions. CORS policy change pending Does it widen what the client may send? no, it narrows Ship it, and enforce the new limit on the actual request too — cached grants cannot be revoked yes Can you tolerate the longest ceiling you serve? no Bump the path to a new version and ship both changes together yes Server first, wait out the ceiling, then client

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.