Using Vary Mismatches as a Cache Diagnostic Signal
A cache hit rate that’s lower than the traffic volume and max-age on a resource would predict is rarely a sign that the cache is broken — it’s usually a sign that the Vary header is fragmenting the cache key across more request-header values than the origin actually needs to distinguish. Because the resulting symptom (a normal-looking 200 response on every request) looks identical to a correctly working cache from the client’s point of view, this failure mode is one of the easiest to misdiagnose as a TTL or purging problem when it’s actually a key-construction problem.
Prerequisite Concepts
Before reproducing the fragmentation, be comfortable with:
- How CDN cache keys are generated —
Varyheaders extend the cache key beyond the URL by folding in the value of the named request headers. - Mapping
Varyheaders to edge routing — how a CDN’s edge routing layer interpretsVarywhen deciding which stored variant to serve. - Cache hit, miss, and bypass mechanics — the baseline hit/miss decision that
Varyfragmentation multiplies into many parallel, mostly-empty cache slots.
Step-by-Step Resolution
Step 1 — Identify the suspect endpoint
Look for a resource with a long max-age or s-maxage, meaningful request volume, and a hit ratio that doesn’t match. Pull the response headers first to see what it’s varying on:
curl -sI https://example.com/api/products \
| grep -iE '(cache-control|vary|age|cf-cache-status)'
Cache-Control: public, max-age=300
Vary: Accept-Encoding, Accept-Language, User-Agent
Age: 0
CF-Cache-Status: MISS
A Vary line naming User-Agent alongside Accept-Encoding and Accept-Language is an immediate red flag: User-Agent strings are close to unique per client, so varying on it fragments the cache almost one-to-one with visitors.
Step 2 — Reproduce with varied request headers
First, confirm the baseline: repeat an identical request several times and confirm Age increments normally.
for i in 1 2 3; do
curl -sI https://example.com/api/products \
-H 'Accept-Encoding: gzip' \
-H 'Accept-Language: en-US' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) Test/1' \
| grep -iE '(age|cf-cache-status)'
sleep 2
done
CF-Cache-Status: HIT
Age: 12
CF-Cache-Status: HIT
Age: 14
CF-Cache-Status: HIT
Age: 16
Now repeat the same request, but change only the User-Agent value each time:
curl -sI https://example.com/api/products \
-H 'Accept-Encoding: gzip' \
-H 'Accept-Language: en-US' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X) Test/2' \
| grep -iE '(age|cf-cache-status)'
CF-Cache-Status: MISS
Age: 0
Step 3 — Compare Age across identical vs varied requests
The comparison is the diagnosis: identical requests produce an incrementing Age and a stable HIT, while requests that vary only the suspect header produce a persistent Age: 0 and MISS regardless of how recently the “same” logical resource was requested by someone else. That combination — cacheable directives present, yet Age never advancing across a population of real clients — is the fingerprint of Vary fragmentation rather than a short TTL or an aggressive purge schedule, both of which would still show Age incrementing for at least some portion of the request population.
Step 4 — Normalize the fragmenting header at the edge
Once the fragmenting header is confirmed, the fix is to collapse it into a small, fixed set of values before the cache key is computed — the same normalization pattern documented for Vary: Accept-Encoding without fragmenting the cache, generalized to any header the origin has over-declared in Vary.
At the origin, stop echoing the raw header and bucket it instead:
map $http_user_agent $ua_bucket {
default "desktop";
"~*Mobile" "mobile";
"~*bot|crawl" "bot";
}
# Origin decides response variation based on $ua_bucket, not the raw header,
# and only advertises Vary for headers that still matter after bucketing.
add_header Vary "Accept-Encoding" always;
If the origin does not actually render different output per User-Agent at all — a common case for JSON API responses — the correct fix is simpler: stop emitting Vary: User-Agent entirely rather than normalizing it, since a header the response never varies on shouldn’t be in Vary in the first place.
Expected Output / Verification
After normalization, repeat the Step 2 reproduction with several distinct User-Agent strings that fall into the same bucket:
curl -sI https://example.com/api/products \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Test/3' \
| grep -iE '(age|cf-cache-status|vary)'
Vary: Accept-Encoding
CF-Cache-Status: HIT
Age: 58
A HIT with a non-zero, incrementing Age across previously-fragmenting request variants confirms the cache key now collapses correctly. Cross-check with the CF-Cache-Status / X-Cache reference if the status header itself still needs interpreting, and re-run the CDN’s cache-key debug tooling — see how to debug CDN cache key mismatches — to confirm the key construction matches expectations at the edge, not just in the response headers.
Edge Cases
Vary: *— a literal*means the response is considered uncacheable by any shared cache under RFC 9111, since no request can be guaranteed to match. If you find this in production, it is almost always a framework default rather than an intentional choice, and it disables caching entirely regardless ofmax-age.Varyvalues case sensitivity in header names, not values — header names listed inVaryare case-insensitive, but the cache key is built from the header values, which may be case-sensitive depending on the header (Accept-Encoding: gzipvsAccept-Encoding: GZIPshould be normalized, but aren’t always).- Client-side variance you can’t see in a single curl run — mobile carriers, corporate proxies, and browser extensions sometimes inject or strip request headers, so a
Varymismatch that looks fine from a developer’s curl session can still fragment in the wild. Sample real request headers from production logs before assuming a header is safe. - CDN-imposed default
Varybehavior — some CDNs vary automatically onAccept-Encodingeven if the origin doesn’t declare it, to avoid serving compressed bodies to clients that can’t decompress them. This default fragmentation is usually already bucketed sensibly by the CDN and is not the same problem as an origin-declaredVary: User-Agent.
Frequently Asked Questions
How do I know a low hit rate is caused by Vary and not something else?
Reproduce the request with an identical header set repeated several times, then again while deliberately varying one header named in the response’s Vary line. If the identical run shows Age incrementing but the varied run keeps returning Age: 0, the cache is fragmenting on that header.
Why does Vary: User-Agent cause such severe fragmentation?
User-Agent strings carry browser version, OS, and device details that are effectively unique per client combination. Varying on it produces close to one cache entry per visitor, which for practical purposes disables shared caching for that resource.
Is normalizing Vary values safe for correctness?
It is safe as long as the normalized buckets still separate every response variant the origin actually produces. For Accept-Encoding this means bucketing to the compression algorithms actually offered; for Accept-Language it means bucketing to the locales the origin actually renders differently, not the full raw header string.
Can removing a header from Vary break clients?
Only remove a header from Vary if the origin response genuinely does not change based on that header’s value. Removing Vary: Accept-Language from a response that actually renders different locales will cause a cache to serve the wrong language to some clients.
Related
- Interpreting X-Cache and CF-Cache-Status headers — read the status header alongside
Ageto confirm whether a miss is caused byVaryor something else entirely. - Using Vary: Accept-Encoding without fragmenting cache — the specific, well-understood normalization pattern this page’s fix generalizes from.
- How to debug CDN cache key mismatches — a broader toolkit for cache-key problems beyond
Varyalone. - How to read and interpret the Age header — background on the signal this page relies on to distinguish fragmentation from a short TTL.