CDN Vary Header Optimization: Content Negotiation Without Cache Fragmentation
Vary header is the most misunderstood cache control mechanism on the modern web. Used correctly, it enables elegant content negotiation. Used carelessly, it silently destroys your CDN hit ratio and inflates origin load by 10x. This guide shows how to get the negotiation without the fragmentation.Every CDN caches based on a key, typically URL + Host. The Vary header extends that key. When your origin responds with Vary: Accept-Encoding, Accept-Language, you are instructing every edge node: do not serve this cached object to a request unless its Accept-Encoding and Accept-Language match the original request that populated the cache. If you understand that sentence, you understand 90% of Vary-related performance issues.
The challenge for performance teams is balancing correctness with efficiency. Content negotiation is essential for responsive images, internationalization, and compression, but naive Vary usage creates an explosion of cache variants. For sites handling millions of requests per day, the difference between a 95% and a 65% edge hit ratio is not academic — it is origin capacity, latency tail, and cost.
1. What Vary Actually Signals to a CDN
Unlike Cache-Control, which controls whether and how long to cache, Vary controls how many versions to cache. It is a response header that lists request headers that influenced content selection.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: en
Vary: Accept-Encoding, Accept-Language
Cache-Control: public, max-age=86400
Content-Encoding: br
In this example, the CDN must maintain at least two dimensions of variants: encoding (br, gzip, identity) and language (en, es, fr). A request for Accept-Language: fr will never receive the cached en object, even if the URL is identical. This is correct behavior for language negotiation, but the CDN now stores 3 encodings × 3 languages = 9 objects per URL instead of one.
Key nuance: CDNs do not vary on headers you omit. If you serve different HTML to mobile vs. desktop but do not send Vary: User-Agent or a normalized equivalent, the CDN will cache the first variant it sees and serve it to everyone — a classic cache poisoning bug. Understanding how CDN DNS resolution routes users to edges makes this worse: the first request to a cold PoP determines what everyone in that region sees.
The Vary Cache Key Formula
Conceptually, most CDNs compute:
cache_key = hash( host + url + sorted(vary_headers_values) )
Some providers, like Fastly and Cloudflare Enterprise, allow you to customize this with Vary plus surrogate keys or custom cache key rules. Others, like CloudFront, require you to explicitly whitelist headers for forwarding — which implicitly creates a Vary-like behavior. Knowing your provider's model is critical before tuning.
Vary if the response body bytes would be different for different values of that header. If you only change a greeting from “Hello” to “Hola” via JavaScript, you do not need Vary: Accept-Language — let the client handle it.2. The Cache Fragmentation Problem
Fragmentation occurs when Vary creates more cache buckets than there are meaningfully different responses. The two worst offenders are User-Agent and Cookie.
| Metric | Edge Processing | Origin Processing | Improvement |
|---|---|---|---|
| Latency (p50) | 8–15 ms | 120–300 ms | 15–20x faster |
| Cache Hit Ratio | 85–95% | N/A | Reduces origin load |
| TLS Handshake | 1–5 ms (nearby PoP) | 50–150 ms | 10–30x faster |
| Bandwidth Cost | $0.01–0.04/GB | $0.05–0.12/GB | 3–5x cheaper |
| Availability | 99.99% (multi-PoP) | 99.9% (single DC) | 10x fewer outages |
User-Agent fragmentation: There are over 10,000 distinct User-Agent strings in a typical day of traffic. Vary: User-Agent tells the CDN to store a separate object for each one. Even with 100 edge nodes, your hit ratio collapses because the long tail never repeats. We audited an e-commerce site using Vary: User-Agent for responsive images; their edge hit ratio was 41%. After normalization, it rose to 93% with identical user experience.
Cookie fragmentation: Vary: Cookie is even more dangerous. Since cookies often contain session IDs, CSRF tokens, or analytics identifiers that are unique per user, this effectively disables caching entirely. Never use Vary: Cookie unless you have normalized the cookie to a tiny allowlist at the edge.
Fragmentation also interacts badly with HTTP/2 and HTTP/3 connection coalescing and with tiered caching. When a shield PoP has 500 variants of the same URL, it cannot efficiently collapse requests, and origin shielding loses its benefit.
3. Optimization Strategies and Normalization
The goal is not to eliminate Vary, but to make its cardinality match your actual content variants. Three techniques cover 95% of cases:
A. Header Normalization at the Edge
Instead of varying on the raw header, have the CDN compute a normalized header and vary on that. For device detection:
# Fastly VCL example - normalize User-Agent to 3 buckets
sub vcl_recv {
if (req.http.User-Agent ~ "Mobile|Android|iPhone") {
set req.http.X-Device = "mobile";
} elsif (req.http.User-Agent ~ "Tablet|iPad") {
set req.http.X-Device = "tablet";
} else {
set req.http.X-Device = "desktop";
}
}
# Origin then responds with: Vary: X-Device
Now the cache key has 3 variants instead of thousands. The same pattern works for Accept-Language: map en-US, en-GB, en-* → en at the edge, and Vary: X-Lang instead of the raw header.
B. Use Client Hints Instead of User-Agent
Modern browsers support Sec-CH-UA-Mobile, Sec-CH-Viewport-Width, and Accept-CH for responsive images. These are low-cardinality by design. Pair them with Vary: Sec-CH-UA-Mobile, Viewport-Width and you get device-appropriate images without fragmentation. This is far more cache-friendly and privacy-preserving than UA sniffing.
C. Separate URLs Instead of Varying
When variants are very different (e.g., AMP vs. canonical, or heavily personalized pricing), do not use Vary at all. Use distinct URLs or query parameters with canonical tags. For e-commerce personalization, serve a static shell cacheably and fetch price/inventory via a separate uncached API call. This keeps the main document cacheable for everyone.
| Strategy | When to Use | Cache Efficiency | Example Vary Header |
|---|---|---|---|
| Raw Vary | Low-cardinality headers only | High if ≤ 6 variants | Vary: Accept-Encoding |
| Edge Normalization | High-cardinality headers with few real variants | High | Vary: X-Device, X-Lang |
| Client Hints | Responsive images, device adaptation | High | Vary: Sec-CH-UA-Mobile, DPR |
| No Vary + Client Logic | Personalization, A/B tests | Medium | No Vary; fetch variant via JS |
| Vary: Cookie / User-Agent | Almost never | Very Low | Vary: Cookie — avoid |
Handling Accept-Encoding Correctly
A common mistake is manually adding Vary: Accept-Encoding on every response. Most CDNs already handle this transparently: they store one compressed and one uncompressed version and negotiate automatically, without fragmenting on every gzip, deflate, br permutation. Check your provider. On Cloudflare, Fastly, and Akamai, you should not add this header for static assets — it is redundant and can double-store. Only add it if your origin does custom negotiation that the CDN does not understand, and even then, normalize to gzip vs br only.
# Nginx - correct: normalize Accept-Encoding to two values
map $http_accept_encoding $cache_encoding {
"~*br" br;
"~*gzip" gzip;
default identity;
}
add_header Vary "Accept-Encoding" always; # only if CDN not auto-handling
# Better: let CDN handle it, remove manual Vary for static assets
Cookie for authenticated pages but forget Cache-Control: private, a CDN may serve one user's account page to another. See our CDN security guide for cache isolation patterns and why performance and security must be configured together.4. CDN-Specific Implementation Patterns
Each CDN exposes Vary differently:
Fastly: Respects origin Vary by default but lets you override with beresp.http.Vary in VCL and define custom cache keys via hash_data(). Recommended: normalize in vcl_recv, then set Vary: X-Normalized-Header in vcl_fetch.
CloudFront: Does not use Vary to build keys; instead you whitelist headers in Cache Policies. Whitelisting User-Agent has the same fragmentation effect as Vary: User-Agent. Use CloudFront Functions to normalize before the cache lookup and only forward the normalized header.
Cloudflare: Honors Vary but also offers Cache Rules and Transform Rules to normalize. Enterprise customers can use Custom Cache Keys to vary on a header without forwarding it to origin, which is ideal for A/B testing: Cache Key: X-Experiment = cookie “exp” → “a|b”.
Akamai / Varnish: Fully respects Vary and supports Vary: * (uncacheable). Use EdgeWorkers or ESI to normalize. Always avoid Vary: * unless you intentionally want to disable caching.
# Cloudflare Worker - A/B test without Vary: Cookie
export default {
async fetch(req) {
const cookie = req.headers.get("Cookie") || "";
const variant = cookie.includes("exp=b") ? "b" : "a";
req.headers.set("X-Experiment", variant);
// Cache varies only on X-Experiment (2 variants)
return fetch(req, { cf: { cacheKey: req.url + "|" + variant } });
}
}
For image CDNs, prefer Vary: Accept for WebP/AVIF negotiation, but normalize to image/webp, image/avif, fallback rather than the full Accept header which can contain quality values. Better yet, use content negotiation via URL suffix (/image.avif vs /image.webp) and let the edge redirect based on Accept — this keeps each format at a stable URL and avoids Vary entirely.
5. Monitoring and Validating Hit Ratio
After changing Vary, validate with real metrics, not just curl:
- Edge hit ratio by URL pattern: Compare before/after for the same path. A healthy static asset should be >90%. If it drops below 75% after adding a Vary, you have fragmentation.
- Cache key cardinality: Log
X-Cache-Keyor equivalent and count distinct keys per URL over 1 hour. More than 10 distinct keys for a single image URL is a red flag. - Origin load: Watch origin RPS and 5xx during peak. Fragmentation shows up as correlated origin spikes when a new PoP warms.
- Test with two variants:
curl -H "Accept-Language: en" -I https://example.com/pagevscurl -H "Accept-Language: fr" -I https://example.com/pageand inspectX-Cache: HIT/MISSandVaryresponse.
Set up alerts for hit ratio drops >5% week-over-week. Many teams add a Vary for a marketing experiment and forget to remove it, permanently degrading performance. Treat Vary changes like cache key schema migrations: review, test in staging with production-like traffic replay, and document the expected cardinality.
Finally, audit existing headers. Run a scan of your top 100 URLs: for url in $(cat urls.txt); do echo $url; curl -sI $url | grep -i vary; done. You will often find legacy Vary: User-Agent, Accept-Encoding, Cookie set by a CMS plugin that no longer applies. Removing one unnecessary Vary can reclaim 20 points of hit ratio overnight.
Optimized Vary is not about clever tricks — it is about aligning cache keys with real content differences. Normalize high-cardinality inputs, keep variant counts in single digits, and let the edge do the mapping. Your users get the right language, the right image format, and the right device layout, while your CDN serves 95% of requests without ever contacting origin.
Frequently Asked Questions
The Vary header tells caches which request headers were used to choose the response. A CDN creates separate cache entries for each unique combination of those header values, so a request with a different Accept-Language or encoding gets the correct variant instead of a mismatched cached copy.
User-Agent has thousands of unique values across browsers and versions. Vary: User-Agent forces the CDN to store a separate object for nearly every visitor, so the cache rarely hits. Normalize User-Agent at the edge into 2–3 buckets like mobile/desktop and vary on that normalized header instead.
Usually no. Most CDNs automatically handle Accept-Encoding by storing gzip and Brotli variants together and serving the best match without needing an explicit Vary. Only add it manually if your origin does custom compression logic the CDN cannot detect.
Do not use Vary: Cookie. Have an edge function read the experiment cookie and set a low-cardinality header like X-Experiment: a or b, then vary only on X-Experiment. This limits cache fragmentation to the number of test variants, typically two or three.