Vary header missing on locale responses
A French reader opens the home page and gets German. Reloading fixes it. It only happens for some readers, only sometimes, and never in development. Somewhere between the origin and the reader, a response that depends on Accept-Language was cached under a key that does not include it.
Root cause: caches key on the URL unless told otherwise
An HTTP cache stores a response under its URL. If two requests to the same URL can produce different bodies, the response must say which request headers it varied on, and it says that with Vary. Without it, the first response wins and is served to everyone.
Locale-negotiated content is the textbook case. A page that reads Accept-Language and renders accordingly produces different bodies for the same URL, and any cache in between — a CDN, a reverse proxy, a corporate intermediary, the browser’s own cache — will happily reuse the first one.
The reason it appears intermittent is that it depends on cache state. The first reader after an eviction determines what everyone behind that cache node sees until it expires, so the bug follows the pattern of cache population rather than the pattern of requests. That is also why it never reproduces locally: there is no shared cache.
Minimal reproducible example
GET / HTTP/1.1
Accept-Language: de-DE
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=600
← no Vary: the language is invisible to the cache
Ten minutes of German for every reader behind that cache node.
The fix, in order of preference
Put the locale in the URL and stop varying
The best fix removes the condition rather than describing it. If localized content lives only at /de/, /fr/ and so on, then the URL already distinguishes the responses and no Vary is needed — every localized page is a plain, fully cacheable resource.
Negotiation then happens exactly once, on the bare root, and produces a redirect rather than a page:
GET / HTTP/1.1
Accept-Language: fr-FR
HTTP/1.1 302 Found
Location: /fr/
Vary: Accept-Language
Cache-Control: public, max-age=60
Only that one small response depends on the header, so only it fragments — and a redirect is cheap enough that fragmenting it costs nothing. This is the arrangement described in locale negotiation strategies, and its caching properties are a large part of why it is recommended.
If content must vary at one URL, say so precisely
Where a single URL genuinely serves multiple languages — an API returning localized error messages, for instance — the header is required and must be exact:
Vary: Accept-Language
Content-Language: fr
Cache-Control: public, max-age=300, stale-while-revalidate=86400
Content-Language is not optional politeness here: it tells intermediaries and clients which language they actually received, which is what makes a cached entry auditable.
Why varying on Cookie is worse than it looks
A locale cookie seems like a natural thing to vary on, and it is the most expensive header you can name.
Vary: Cookie keys the cache on the entire cookie header, not on the one cookie you care about. Any analytics cookie, session identifier or consent token makes each reader’s cookie header unique, so every reader gets their own cache entry and the shared cache stops sharing anything. On a site with a session cookie the hit rate approaches zero.
Two ways out. The first is to not vary on cookies at all — express the locale in the URL, and let the cookie influence only the redirect at the root. The second, where an edge platform supports it, is to normalise the cache key: extract the single locale cookie into a derived key and ignore the rest. Both approaches share the same goal, which is to make the cache key contain the smallest amount of information that actually changes the response.
A related trap is varying on User-Agent, which some frameworks add automatically for device-specific rendering. Combined with Accept-Language it multiplies the fragmentation, and User-Agent has effectively unbounded cardinality.
Finding the cache that is actually holding the response
When a language leaks, the first useful question is which cache did it, because the fix differs at each layer and there are usually four candidates between the origin and the reader.
The browser cache is the easiest to rule out: a private-window request that reproduces the problem exonerates it. It is also the one layer where a missing Vary is least harmful, since the cache is not shared.
The service worker, if the site has one, is a cache the network panel will not show as a network request at all. A response served from a worker appears instantly and carries no upstream headers, which is the tell. Workers implement their own key policy, so a Vary header does nothing unless the worker’s fetch handler was written to honour it.
The CDN identifies itself in response headers — an age, a cache status, a node identifier — and those headers are the fastest way to confirm a hit and to see how old the entry is. A response with a large age and the wrong language is conclusive.
A corporate or ISP proxy is the layer you cannot inspect and cannot fix, which is the strongest argument for an architecture that does not depend on any intermediary respecting Vary.
Working down that list takes a few minutes and eliminates most of the guesswork. It also tends to settle the design argument: once a team has spent an afternoon establishing that an unknown intermediary cached a German page for French readers, moving the locale into the URL stops looking like extra work.
Verification
# The localized page must not vary at all — the URL is the key
curl -sI https://example.com/fr/pricing | grep -iE '^(vary|content-language|cache-control)'
# content-language: fr
# cache-control: public, max-age=600
# The root redirect must vary on the header it read
curl -sI -H 'Accept-Language: fr' https://example.com/ | grep -iE '^(vary|location)'
# vary: accept-language
# location: /fr/
# Cross-check: two languages, one URL, two different bodies
diff <(curl -s -H 'Accept-Language: de' https://example.com/fr/pricing) \
<(curl -s -H 'Accept-Language: fr' https://example.com/fr/pricing)
# (no output — a prefixed URL must ignore the header entirely)
The third check is the one worth keeping. A prefixed URL whose body changes with the header is a page that still negotiates when it should not, and that is the state in which a missing Vary does damage.
When to escalate
If Vary is correct and readers still see the wrong language, look for a cache that ignores it. Some intermediaries treat Vary conservatively or not at all, which is another argument for URL-based localization: it does not require the cache to cooperate.
If the hit rate collapses after adding Vary: Accept-Language, that is expected — browsers send widely varying header strings, so the cardinality is high. Normalising the header to a supported locale at the edge before it reaches the cache key restores most of the benefit, and platforms that expose cache-key manipulation make this straightforward.
If a service worker is serving stale localized content, remember it is a cache too, with its own key policy. A worker caching by URL alone reproduces this bug entirely on the client, where no Vary header helps.
FAQ
Is Vary: Accept-Language ever enough on its own?
For correctness, yes. For performance, rarely — the header’s cardinality is high enough that a shared cache fragments badly. It is the right answer for a small number of URLs and the wrong architecture for a whole site.
Should the localized page still send Content-Language?
Yes. It costs nothing, it tells clients and intermediaries what they received, and it is the response-level counterpart to the lang attribute in the markup. Disagreement between the two is a real bug that this header makes visible.
What about Accept-Language in an API?
APIs are the case where varying at one URL is genuinely reasonable, because clients cannot always choose a path. Set Vary: Accept-Language, normalise the header to a supported locale before it becomes a cache key, and keep the cacheable lifetime short.
Does a locale in a query parameter work instead of a path?
It works as a cache key, since parameters are part of the URL, but it is worse for everything else: parameters are stripped by some tools, ignored in canonical annotations, and awkward for readers. A path segment is the same cache benefit with none of those costs.
Related
- Locale Negotiation Strategies — the negotiate-once-then-redirect model that removes the need to vary.
- Fallback Chain Configuration — the cache states a resolved locale entry moves through.
- Detecting locale from Accept-Language header edge cases — parsing the header the redirect depends on.
- Locale-Aware SEO & hreflang — why crawlers need every locale reachable without header negotiation.
Part of Locale Negotiation Strategies.