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:

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.

Vary Fragmentation: Identical vs Varied Requests Two parallel request sequences: identical repeated requests hit the same cache entry with incrementing Age, while requests with a varying User-Agent each create a new cache entry with Age stuck at zero. Identical requests (same headers) Request A1 Request A2 Request A3 One cache entry Age: 12 → 14 → 16 (HIT) Varied requests (User-Agent changes) Request B1 Request B2 Request B3 Entry 1 Age: 0 (MISS) Entry 2 Age: 0 (MISS) Entry 3 Age: 0 (MISS)

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 of max-age.
  • Vary values case sensitivity in header names, not values — header names listed in Vary are case-insensitive, but the cache key is built from the header values, which may be case-sensitive depending on the header (Accept-Encoding: gzip vs Accept-Encoding: GZIP should 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 Vary mismatch 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 Vary behavior — some CDNs vary automatically on Accept-Encoding even 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-declared Vary: 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.


Back to Cache Debugging and Observability