The Header That Poisons Caches
Your site serves different content to mobile and desktop from the same URL. Sensible enough. You read User-Agent on the server and return the appropriate version.
Then a CDN caches the desktop version and serves it to every mobile visitor for the next hour. Or worse: a mobile version gets cached and served to everyone.
The fix is one header you almost certainly forgot:
Vary: User-Agent
Vary is how you tell every cache between your server and the browser that a response depends on something in the request. Get it wrong and caches serve the wrong thing to the wrong people — which, depending on what varies, ranges from a cosmetic annoyance to a security incident.
What Vary Actually Does
A cache stores responses keyed by URL. When a second request arrives for the same URL, it serves the stored copy.
Vary extends that key. Vary: Accept-Encoding tells the cache to store separate entries for each distinct Accept-Encoding value it sees, and to match on that header as well as the URL.
Request A: GET /page Accept-Encoding: gzip → cached as (/page, gzip)
Request B: GET /page Accept-Encoding: br → cache miss, fetches fresh
Without the Vary header, request B gets served the gzip-encoded response regardless of whether it can decode it.
The Headers You Almost Always Need
Vary: Accept-Encoding
The single most important one. If your server compresses responses — and it should — this is mandatory.
Serving a gzip-compressed body to a client that didn't request compression produces garbage. Most web servers add this automatically when compression is enabled, but verify rather than assume, particularly if you have a proxy or CDN in the chain doing its own compression.
Vary: Accept
For endpoints that content-negotiate between formats. An API returning JSON or XML depending on the Accept header must vary on it, or a JSON response gets cached and served to an XML client.
Vary: Accept-Language
If your server picks a language based on browser preferences rather than URL, you must vary on this. Otherwise the first visitor's language gets served to everyone.
This is one reason URL-based localisation (/en/, /de/) is generally preferable to header-based — it's cacheable per URL with no Vary needed, and it gives search engines distinct URLs to index.
Vary: Origin
Critical for CORS, and frequently missed.
If your server returns Access-Control-Allow-Origin reflecting the request's Origin header, the response varies by origin. Without Vary: Origin, a cache can store the response for origin A and serve it to origin B — where the browser then rejects it because the allowed origin doesn't match.
This produces intermittent, hard-to-reproduce CORS failures that depend entirely on cache state. It's one of the more frustrating debugging experiences in web development, and the fix is one header.
The Ones to Avoid
Vary: User-Agent
Technically correct if you serve different content per device. Practically, it's close to disabling caching entirely.
There are an enormous number of distinct User-Agent strings in circulation — every browser version, every OS build, every device. Varying on it means a separate cache entry per unique string, and your hit rate collapses.
Better approaches:
- Responsive design. One response, adapted client-side. No
Varyneeded. - Client Hints.
Vary: Sec-CH-UA-Mobilevaries on a boolean rather than a full UA string, giving two cache entries instead of thousands. - CDN device detection. Many CDNs normalise UA into a small set of device classes and vary on that internally.
Vary: Cookie
Varying on the full Cookie header is effectively per-user caching, since analytics and session cookies make almost every request unique.
If a page genuinely differs per logged-in user, the correct answer is usually Cache-Control: private — which permits browser caching but prevents shared caches from storing it — rather than trying to vary on cookies.
For pages that differ only between "logged in" and "logged out", a common pattern is to serve the same cacheable shell to everyone and fetch personalised fragments client-side.
Vary: *
Means the response varies on unspecified factors and must not be cached in any shared cache. If that's what you want, say it directly with Cache-Control: no-store.
Checking What You're Sending
The HTTP Headers Checker shows the full response headers for any URL:
- Enter the URL.
- Read the returned response headers.
- Look for
Vary,Cache-Control, and anyAgeheader from an intermediate cache.
What to look for:
Is Vary: Accept-Encoding present on compressed responses? Check Content-Encoding too — if it says gzip or br and there's no Vary, that's a bug.
Is Vary unexpectedly broad? A Vary: User-Agent, Cookie, Accept-Language on a static page is destroying your cache hit rate for no benefit.
Does the Vary list match what actually varies? Varying on headers that don't affect the response costs cache efficiency and gains nothing.
Is there a Vary on a response that should be identical for everyone? Static assets — CSS, JS, images — usually need only Accept-Encoding, if that.
Compare the headers on a static asset against those on a dynamic page. They should look quite different, and if they don't, something in your configuration is applying one policy to everything.
Vary and Cache-Control Together
These two headers work together and are frequently confused.
Cache-Control says whether and for how long a response may be cached.
Vary says what the cache key includes.
Both matter. A response with Cache-Control: public, max-age=3600 and no Vary will be cached for an hour and served identically to everyone — correct for a static asset, wrong for anything negotiated.
A useful combination for content-negotiated pages:
Cache-Control: public, max-age=300
Vary: Accept-Encoding, Accept-Language
For per-user content:
Cache-Control: private, max-age=0, must-revalidate
The private directive is doing the important work here — it permits browser caching while excluding shared caches, which is usually what you want for authenticated pages.
Where Vary Gets Ignored
Worth knowing that Vary isn't uniformly respected:
Some CDNs limit which headers they'll vary on. Several ignore Vary: User-Agent specifically, precisely because it's so destructive to hit rates, and substitute their own device normalisation. Check your CDN's documentation rather than assuming standard behaviour.
Browser caches implement it more loosely than shared caches. Behaviour around Vary matching has historically differed between browsers.
Normalisation matters. A cache that varies on Accept-Encoding may see gzip, deflate, gzip,deflate, and deflate, gzip as three distinct values, tripling storage for identical responses. Good CDNs normalise these; not all do.
The practical implication: verify behaviour with your actual CDN rather than reasoning purely from the specification.
Practical Tips
Add Vary: Accept-Encoding wherever you compress. Check it's actually there rather than trusting the server default.
Add Vary: Origin wherever you reflect the Origin header in CORS responses. This one causes genuinely baffling bugs when missing.
Keep the Vary list as short as possible. Every header you add multiplies your cache entries.
Prefer URL-based differentiation over header-based. Different languages at different URLs, different formats at different endpoints. Cacheable without Vary and better for search engines.
Use Cache-Control: private rather than varying on cookies for per-user content.
Test cache behaviour, don't assume it. Request the same URL with different header values and check whether you get the right response and what the Age header says.
FAQ
What does the Vary header do? It tells caches which request headers form part of the cache key, so a response is only reused for requests with matching values.
Do I need Vary: Accept-Encoding? Yes, if your server compresses responses. Without it, compressed content can be served to clients that didn't request compression.
Why does Vary: User-Agent hurt performance? The huge number of distinct UA strings means a separate cache entry for nearly every visitor, collapsing your hit rate.
What's the difference between Vary and Cache-Control?
Cache-Control governs whether and how long to cache. Vary governs what counts as the same request for cache-matching purposes.
Why am I getting intermittent CORS errors?
A missing Vary: Origin on a response that reflects the request origin is a classic cause — cached responses get served to the wrong origin.
Can I vary on a custom header? Yes, any request header can be named. Whether your CDN honours it is worth verifying.
The Takeaway
Vary is the header that makes content negotiation and caching coexist. Too little and caches serve wrong content; too much and they serve nothing at all. Keep the list minimal, always include Accept-Encoding where you compress and Origin where you do CORS, and prefer differentiating by URL wherever you have the choice.
Inspect response headers for any URL free with the HTTP Headers Checker at sadiqbd.com — no sign-up, instant results.