The Vary header is a multiplier on your cache key. A CDN edge stores one copy of an object per distinct combination of the request headers listed in Vary, so Vary: Accept-Encoding, Accept-Language on a catalog served in 12 locales can turn one URL into 36 or more cache entries. In a 1-million-URL model with a 24-hour TTL and eight requests per URL per TTL, that shift takes edge hit ratio from 87.5% to effectively zero and multiplies origin egress by 8x. Normalization before the cache key is computed is the fix, and it costs about ten lines of config.
Per RFC 9111, Vary defines the secondary cache key: a stored response can only be reused for a request whose listed headers match the ones on the original request. The primary key is the method plus URL. The secondary key is everything Vary names. The cache does not know which parts of those headers actually changed the response body — it matches on what it was told to match on.
That is why fragmentation is silent. Nothing errors. Requests still return 200. The edge simply keeps missing, refilling, and evicting variants that differ only in whitespace or token order, while your dashboard shows a hit ratio sliding two points a quarter and nobody can name the cause.
Model assumptions, stated so you can substitute your own: 1,000,000 unique URLs, mean object size 200 KB, 24-hour TTL, and eight requests per URL per TTL reaching a given edge tier (long-tail-heavy, typical of software catalogs and media libraries). Each distinct variant costs one additional origin fill.
A Vary header multiplies a CDN's cache entries: the edge stores one object per distinct combination of the listed request headers. In this 2026 model of a 1-million-URL catalog with a 24-hour TTL and eight requests per URL per TTL, normalizing Accept-Encoding to three buckets holds edge hit ratio near 62%, while passing raw client values through — commonly 6 to 12 distinct strings per object, with tail measurements finding hundreds — drops it to roughly 25% and triples origin egress.
| Vary value | Distinct values seen in the wild | Safe at the edge? | Handling |
|---|---|---|---|
| Accept-Encoding | 6–12 typical, hundreds in the tail (estimate) | Yes, after normalization | Collapse to zstd / br / gzip / identity before the cache key |
| Accept-Language | 20–500 raw strings | Only after mapping | Map to the locale set you actually publish; cap variants at that number |
| Accept (image negotiation) | 5–20 | Yes, bucketed | Three buckets: avif, webp, legacy |
| Origin (CORS) | One per calling origin | Yes if the allowlist is short | Otherwise return a fixed allow-origin value and drop Vary |
| User-Agent | 10,000 to 1,000,000+ | No | Have the edge set a device-class header with 2–3 values and Vary on that |
| Cookie | Effectively unbounded | No | Strip analytics cookies at the edge, or mark the route uncacheable and be honest about it |
| Vary: * | n/a | Never cacheable | Remove it unless you mean "do not store" |
The single rule that falls out of this table: only Vary on a header whose value space you control, and if you do not control it, normalize it into a set you do.
Rewrite the request header before the cache lookup, then still emit Vary: Accept-Encoding downstream so browsers and intermediate caches behave correctly. nginx as a caching proxy:
map $http_accept_encoding $norm_ae {
default "";
"~*zstd" "zstd";
"~*br" "br";
"~*gzip" "gzip";
}
server {
location / {
proxy_set_header Accept-Encoding $norm_ae;
proxy_cache edge_zone;
proxy_cache_key "$scheme$request_method$host$request_uri$norm_ae";
proxy_pass http://origin_upstream;
}
}
Varnish, same idea in vcl_recv, applied before the built-in lookup:
sub vcl_recv {
if (req.http.Accept-Encoding ~ "zstd") {
set req.http.Accept-Encoding = "zstd";
} elsif (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} elsif (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
unset req.http.Accept-Encoding;
}
if (req.url ~ "\.(mp4|m4s|jpg|png|webp|zip|gz)$") {
unset req.http.Accept-Encoding;
}
}
The second block matters more than most teams expect. Already-compressed media gains nothing from transfer encoding, and a Vary header on those objects doubles or triples the entry count for zero byte savings — on a video library that is the difference between one variant per segment and three.
Collapsing to fewer buckets costs bytes. Brotli typically ships text 15–20% smaller than gzip; if you normalize everything to gzip to get one variant, you pay that on every text response. Three buckets is usually the right compromise, two if your traffic is overwhelmingly modern browsers.
Serving compressed-on-the-fly at the edge avoids variants entirely but moves CPU to the edge tier and complicates range requests on large objects. It is a reasonable choice for small text assets and a poor one for anything above a few hundred KB.
The dangerous failure is a mismatch between what the edge keys on and what the origin actually varies. If the origin returns Brotli but forgets Vary: Accept-Encoding, a client that only accepts gzip eventually receives a Brotli body and renders binary garbage. Conditional requests make this worse: a weak ETag reused across variants can produce a 304 that revalidates the wrong stored copy.
Purge behavior is the second trap. Some CDNs invalidate every variant behind a URL; others invalidate only the variant matching the purge request's headers, leaving stale siblings alive until TTL expiry. Test this explicitly before you rely on purge for incident response.
The observability gap is that almost every hit-ratio dashboard aggregates by URL, not by variant. Fragmentation is invisible until you log the computed cache key or the normalized header alongside the hit or miss status. Whether header normalization runs before the cache key is computed is worth confirming during vendor evaluation; BlazingCDN exposes this kind of edge cache key and header configuration per customer rather than requiring an origin-side workaround.
The Vary header tells a CDN which request headers form the secondary cache key, so the edge stores and serves a separate copy of the object for each distinct combination of those header values. It exists to prevent a cache from serving a Brotli body to a gzip-only client, and it directly determines how many entries one URL occupies.
Vary: Accept-Encoding is safe only when the edge normalizes the header first. Raw client values commonly resolve to 6–12 distinct strings per object because of token order and whitespace differences, splitting one URL across that many cache slots. Collapsing to zstd, Brotli, gzip, and identity holds the variant count at four or fewer.
User-Agent has effectively unbounded cardinality — tens of thousands to over a million distinct strings in production traffic — so varying on it gives nearly every client its own cache entry and drives hit ratio toward zero. Detect the device at the edge, set a normalized device-class header with two or three values, and vary on that instead.
Pick your ten highest-traffic URLs and pull 24 hours of edge logs with the raw Accept-Encoding, Accept-Language, and cache status fields. Count distinct header values per URL, multiply them, and compare that product against your average requests-per-URL-per-TTL. If the product is larger, those objects are never reaching a steady-state hit — they are being refilled continuously, and your origin bill reflects it. Then apply three-bucket normalization to one hostname, hold it for a week, and measure the change in origin fills rather than hit ratio. Fills are the number that maps to money.