How a Single Vary Header Destroys Your CDN Hit Rate
This walkthrough extends CDN Edge Caching Configuration within Advanced Caching Strategies & CDN Architecture. Your Cache-Control is correct, your TTLs are generous, and your hit ratio is 41%. The cause is usually one line: a Vary header naming something with near-infinite cardinality, which instructs every cache to store a separate copy of the response per distinct value of that header.
Vary: User-Agent alone can turn one cacheable object into tens of thousands of near-identical ones, none of which is ever warm.
Rapid Diagnosis: Confirm Fragmentation
- Read the response's
Varyheader. Anything beyondAccept-Encoding— and occasionallyAccept— deserves justification. - Check the hit ratio by path. Fragmentation shows as a low ratio on a path whose content is identical for everyone; static assets with a bad
Varyare the clearest case. - Look for
Vary: Cookieon HTML. Any cookie value change — including analytics IDs that differ per visitor — creates a new cache entry. With per-visitor cookies, the hit ratio for that path is effectively zero. - Compare edge and origin request volume. If origin traffic tracks visitor count rather than content count, the cache is not doing anything.
Root Cause Analysis
1. Vary: User-Agent on anything. User agent strings are effectively unique per browser build. Even where content genuinely differs by device, the correct key is a device class of two or three values, not the raw string.
2. Vary: Cookie on shared responses. Cookies carry per-visitor identifiers, so varying on the whole header guarantees a unique key per visitor. If a response really depends on one cookie, key on that cookie's value alone — most CDNs support this directly.
3. Framework defaults that add Vary silently. Compression middleware adds Accept-Encoding (correct and harmless), but content negotiation, locale detection or session middleware can add much wider values without anyone deciding to.
4. Vary: *. This means "never reuse this response for any other request" — a complete cache disable. It occasionally appears from a security middleware and is always worth challenging.
Step-by-Step Resolution
1. Audit what you are actually sending.
# Every distinct Vary value across the routes that matter.
for p in / /products /products/widget /assets/app.css; do
printf '%-24s ' "$p"
curl -sI "https://example.com$p" | awk 'tolower($1)=="vary:"{ $1=""; print }'
done
# trade-off: this reads the edge's response. Check the ORIGIN too — a CDN can
# strip or rewrite Vary, so a clean edge response may hide a wide origin value
# that fragments an intermediate cache.
Expected outcome: a short list of offending routes, usually one or two middleware sources.
2. Reduce Vary to what genuinely changes the bytes. For most responses that is Accept-Encoding alone, plus Accept where you negotiate image formats.
# Static assets: identical for everyone, regardless of who asks.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Vary "Accept-Encoding" always; # nothing else varies the bytes
}
# trade-off: dropping Vary values that DO change the response serves the wrong
# body to someone. Confirm per route that the response is genuinely identical
# before narrowing — a wrong Vary is a correctness bug, not just a slow cache.
Expected outcome: static assets return to a near-100% hit ratio immediately.
3. Replace wide headers with a normalised cache key. Where the response really does differ, derive a low-cardinality value at the edge and key on that.
// Edge worker: classify the device, then vary on the derived class only.
export default {
async fetch(request) {
const ua = request.headers.get('user-agent') || '';
const cls = /Mobi|Android/i.test(ua) ? 'mobile'
: /Tablet|iPad/i.test(ua) ? 'tablet' : 'desktop';
const keyed = new Request(request);
keyed.headers.set('x-device-class', cls); // cache key includes this
const res = await fetch(keyed);
const out = new Response(res.body, res);
out.headers.set('Vary', 'Accept-Encoding, x-device-class');
return out;
}
};
// trade-off: a derived class is a lossy summary. A device that straddles the
// regex gets the wrong variant — acceptable for layout tweaks, not for
// anything where the wrong response is a bug.
Expected outcome: cardinality falls from tens of thousands to single digits, and the hit ratio recovers to its structural maximum.
4. Split personalised fragments out of cacheable HTML. The strongest fix is architectural: cache the shared shell aggressively and fetch the per-user parts separately, so nothing needs to vary on Cookie at all. That approach pairs naturally with stale-while-revalidate on the shell.
Verification
Watch the hit ratio per path for 24 hours after the change — it should rise quickly for static assets and more slowly for HTML as the smaller key space warms. Track origin request volume at the same time: a real fix reduces it proportionally, and if origin traffic is unchanged the cache key is still fragmented somewhere upstream.
Then check correctness explicitly, because narrowing Vary is the one cache change that can serve a wrong response:
# The same URL with different request shapes should return the right variant.
curl -s -H 'User-Agent: Mozilla/5.0 (iPhone…)' https://example.com/products | md5sum
curl -s -H 'User-Agent: Mozilla/5.0 (Windows…)' https://example.com/products | md5sum
# Identical hashes mean the variants were never really different — you can drop
# the device split entirely. Different hashes confirm the normalised key is
# doing real work.
Add a monitor on Vary for the routes you fixed. Middleware upgrades reintroduce these headers regularly, and the symptom — a slowly falling hit ratio — is easy to miss for weeks.
FAQ
Is Vary: Accept-Encoding always safe?
Yes, and it is required whenever you serve compressed responses, because a client that cannot accept Brotli must not be handed a Brotli body. Its cardinality is tiny — a handful of encoding combinations in practice — so it costs almost nothing in fragmentation. It is the one Vary value you should expect to see on nearly every compressible response.
How do I serve device-specific HTML without wrecking the cache?
Normalise to a small device class at the edge and vary on that derived value, or better, serve one responsive document to everyone and let CSS handle the difference. The responsive route removes the problem instead of managing it, and it also removes an entire class of bugs where a misclassified device receives the wrong layout.
Does Vary affect the browser cache too?
Yes — it applies to every cache in the chain, including the browser's. A response with Vary: Cookie will miss in the private cache as soon as any cookie changes, so a visitor who receives a new analytics identifier re-downloads a page they already had. The fragmentation cost is therefore paid twice: once at the edge, once per user.
What hit ratio should I expect after fixing this?
For content-hashed static assets, essentially 100% once the cache is warm — anything less means the key is still fragmented or the TTL is too short. For HTML the ceiling depends on how much of your traffic is cacheable at all: a fully static site can reach the high nineties, while a site with personalised sections will sit lower by design. Judge the number against the theoretical maximum for that path rather than against a universal target.
Can I remove Vary entirely on static assets?
Not if you serve compressed responses — omitting Vary: Accept-Encoding risks handing a Brotli body to a client that cannot decode it, via any shared cache in between. What you can do is ensure it is the only value present. Some CDNs normalise encoding internally and manage the variants themselves, in which case the header is still correct to send and simply costs nothing.
How do I catch a bad Vary before it reaches production?
Assert on the response headers in the same smoke test that checks status codes after a deploy. A short list of routes with their expected Cache-Control and Vary values, checked with a HEAD request, catches the middleware upgrade that quietly adds Cookie to the list. The alternative is noticing weeks later when someone finally asks why origin traffic has been climbing.
Related
- CDN Edge Caching Configuration — cache keys, TTLs and hit-ratio fundamentals.
- HTTP Cache-Control Headers Explained — the directives that decide storage and freshness once the key is right.
- Stale-While-Revalidate Implementation — serve the cached shell instantly while refreshing it behind the request.