The header that decides what your cache stores
Vary is the single most misunderstood caching header. It tells every cache —
the browser, a CDN, a reverse proxy — which request headers must be part of the
cache key. Get it right and content negotiation works through caches; get it
wrong and you either serve the wrong variant or shred your hit rate. This
reference rates the common Vary field names by safety and cache impact.
How Vary builds the cache key
When a response includes Vary, a cache stores one entry per distinct
combination of the listed request-header values:
Vary: Accept-Encoding, Accept-Language
A request only matches a stored entry if its values for those headers match. So
varying on Accept-Encoding keeps gzip and Brotli bodies separate (correct),
while varying on Cookie or User-Agent fragments the cache into near-unique
entries (harmful). CDNs often normalise values — collapsing dozens of
Accept-Encoding strings down to gzip/br/identity — to keep the key small.
Field-by-field guide
| Field | Use | Cache impact |
|---|---|---|
Accept-Encoding | Separate compressed variants | Low — CDNs normalise to a handful of values |
Accept-Language | Serve localised content | Medium — keep languages few or use URL-based routing instead |
Accept | Serve different content types (JSON vs HTML) | Low for APIs; prefer separate URLs for high-traffic content |
Origin | CORS preflight caching | Low — only a handful of origins per resource |
Cookie | Per-session personalisation | Very high — effectively uncacheable; never use on shared assets |
User-Agent | Serve browser-specific markup | Very high — thousands of distinct strings; use CDN normalisation or UA groups |
* | Varies on something outside headers | Uncacheable by shared caches; use sparingly |
The CDN normalisation difference
Browsers send Vary exactly as written. CDNs add a layer of intelligence: many
automatically normalise Accept-Encoding into a small set of canonical values
(br, gzip, identity) so the cache does not fragment on minor encoding
string variations. Check your CDN’s documentation to understand what it normalises
— a field that fragments the cache badly in an origin cache may be safe on a CDN
that handles it.
Tips and pitfalls
- Always send
Vary: Accept-Encodingfor compressed responses so caches do not serve an encoded body to a client that only accepts plain text. - Avoid
Vary: CookieandVary: User-Agenton cacheable assets — they reduce cache hit rates toward zero. Vary: Originis the correct way to handle CORS; it ensures each origin’s preflight response is cached independently.Vary: *makes a response effectively uncacheable by shared caches — use it only when negotiation depends on something the cache genuinely cannot see.- Keep the list as short as possible; each additional field multiplies the number of distinct stored variants and divides your cache hit rate accordingly.
Why cache fragmentation compounds
The damage from over-varying is multiplicative, not additive. Each field the cache keys on multiplies the number of stored variants by the number of distinct values that field takes:
| Vary list | Distinct values seen | Variants stored | Effect |
|---|---|---|---|
Accept-Encoding | ~3 (br/gzip/identity) | 3 | Negligible |
Accept-Encoding, Accept-Language | 3 × 20 | 60 | Manageable |
Accept-Encoding, Cookie | 3 × ~1 per user | ~3 per user | Cache effectively dead |
A single object cached under Vary: Cookie becomes a per-user object — the shared
cache degrades to a private cache, and origin load rises to what it would be with no
cache at all. This is why the fix for a “why is my CDN hit rate 4%?” incident is
almost always a stray Vary: Cookie or Vary: User-Agent emitted by a framework
default.
Correct pairing with Cache-Control
Vary decides which variant a request maps to; Cache-Control decides whether
and how long any variant is stored. They must agree: marking a response
Cache-Control: private alongside Vary: Accept-Encoding still keeps it out of
shared caches, so the Vary is moot. For a compressible, publicly cacheable asset
the correct pairing is Cache-Control: public, max-age=... plus
Vary: Accept-Encoding and nothing else.
Debugging a Vary problem in five minutes
The fastest diagnostic: request the same URL twice with different values of
the header you suspect (for example Accept-Encoding: gzip then identity)
and compare the Age and cache-status headers of the responses. A cache
serving one variant to both requests means Vary is missing; a cache that
never serves a hit at all on a personalised endpoint often means an
over-broad Vary: Cookie or Vary: * is fragmenting the cache into
one-request-per-variant. Both failure modes are invisible in a single request
— always test in pairs.
A closing rule of thumb: every value you add to Vary multiplies the number of cache entries a URL can occupy, so treat each addition as a deliberate trade-off between correctness and hit ratio — normalise header values at the edge where you can, and prefer one well-chosen variant key to three accidental ones.
Sources
- IETF — RFC 9110 §12.5.5: Vary — the normative semantics.
- MDN Web Docs — HTTP Vary header — practical guidance and examples.