Vary header: Accept-Encoding, User-Agent and * (tested)
The Vary response header lists the request headers that change the response, so a cache must only reuse a stored copy when those request headers match. We counted server hits in Chromium 153 for five Vary values to see when the browser cache really reuses a response and when it goes back to the server.
1. The results
Every response had Cache-Control: max-age=60. Numbers are requests that reached the server for that URL; 1 means every later request was answered from Chromium's HTTP cache.
| Vary | Same fetch twice | X-Custom 1, 2, then 1 again | UA changed (DevTools override) | Accept-Encoding changed | Back button | Reload |
|---|---|---|---|---|---|---|
| (none) | 1 | 1 | 1 | 1 | cache | server |
Accept-Encoding | 1 | 1 | 1 | 2 | cache | server |
User-Agent | 1 | 1 | 2 | 1 | cache | server |
X-Custom | 1 | 3 | 1 | 1 | cache | server |
Accept-Encoding, User-Agent | 1 | 1 | 2 | 2 | cache | server |
* | 2 | 3 | 2 | 2 | server | server |
What the numbers show:
- Vary only matters when the named request header changes. With an unchanged browser,
Vary: Accept-EncodingandVary: User-Agentcached exactly like no Vary at all: one server hit, then cache. - Chromium keeps one variant per URL. With
Vary: X-Custom, fetching withX-Custom: 1, then2, then1again cost 3 server hits, not 2. The2response replaced the1response, so switching back missed. A header that alternates between values makes the browser cache close to useless for that URL. Vary: *is never reused. Two identical fetches hit the server twice, and even the back button refetched the page (3 hits after goto, goto, back). It behaves likeno-cachefor Chromium's HTTP cache.- Reload always went to the server, whatever the Vary value, and sent
Cache-Control: max-age=0. Plain back/forward reused the cached page unless Vary was*.
2. You cannot change User-Agent or Accept-Encoding from fetch()
We called fetch(url, {headers: {'User-Agent': 'OtherUA/1.0'}}). Chromium 153 silently dropped the header: the server received the normal HeadlessChrome/153.0.0.0 user agent and, with Vary: User-Agent, the response came from cache. Accept-Encoding cannot be set from page JavaScript either; Chromium always sent gzip, deflate, br, zstd. So for a real browser those two Vary values only split the cache when the browser itself changes (DevTools device emulation, a UA override, an update that changes the version string). We changed them with DevTools protocol overrides (Network.setUserAgentOverride, extra headers) to get the columns above.
3. Vary: Accept-Encoding: send it on every response
If a server picks gzip or plain bytes based on Accept-Encoding, the response must say Vary: Accept-Encoding on both branches, or a shared cache can store the gzip version and hand it to a client that cannot decode it. Node.js, checked with curl 8.14.1 (both responses carried Vary: Accept-Encoding, only the first had Content-Encoding: gzip):
const http = require('http');
const zlib = require('zlib');
http.createServer((req, res) => {
const body = JSON.stringify({ hello: 'world' });
const gzip = /\bgzip\b/.test(req.headers['accept-encoding'] || '');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Vary', 'Accept-Encoding'); // on BOTH branches
if (gzip) {
res.setHeader('Content-Encoding', 'gzip');
res.end(zlib.gzipSync(body));
} else {
res.end(body);
}
}).listen(8967);
nginx (config only, not run in this test): gzip_vary on; adds Vary: Accept-Encoding to responses that the gzip module could compress.
gzip on;
gzip_vary on;
gzip_types text/css application/javascript application/json image/svg+xml;
If you append to Vary yourself, merge values into one header (Vary: Accept-Encoding, Origin) rather than overwriting what a framework already set.
4. Vary: User-Agent and Vary: *: usually avoid
Documentation, not measured here: shared caches and CDNs key on the full header value, and real User-Agent strings vary per browser version and OS, so Vary: User-Agent splits a CDN cache into many near-identical copies or makes it skip caching. Some CDNs only honour a short list of Vary values and treat others as uncacheable; check your CDN's docs. If you must serve different HTML per device, prefer separate URLs or client hints with a small value set. For Vary: *, RFC 9110 says the response cannot be matched by a later request without contacting the origin, which is what we measured in Chromium. If you want "do not reuse", say it directly with Cache-Control: no-cache or no-store.
5. Vary: Origin
For CORS responses that echo the request origin, the important value is Vary: Origin; we measured that case with an nginx cache in Vary: Origin, CORS and caching.
Frequently asked questions
What does the Vary header do?
It names the request headers that change the response, so a cache only reuses a stored response when those request headers match. In Chromium 153, Vary: X-Custom made a request with a different X-Custom value go back to the server.
Does Vary: Accept-Encoding stop the browser from caching?
No. With the same browser the Accept-Encoding value never changes, and a response with Vary: Accept-Encoding and max-age=60 was fetched once and then served from cache, same as no Vary.
What does Vary: * mean?
That no later request can be assumed to match. Chromium 153 never reused a Vary: * response: two identical fetches both hit the server and the back button refetched the page.
Should I use Vary: User-Agent?
Rarely. It does not hurt one browser's cache (the UA does not change), but documented CDN behaviour is to split or skip caching on it because User-Agent strings are so varied. Prefer separate URLs or client hints.
Does Chrome cache several Vary variants of one URL?
No, not in our test. Alternating X-Custom 1, 2, 1 against Vary: X-Custom cost three server hits: each new variant replaced the previous one.
Can I change User-Agent with fetch()?
Not in Chromium 153: a User-Agent header passed to fetch() was dropped and the server saw the normal browser UA. Accept-Encoding cannot be set from page JavaScript either.
Written with AI assistance. Measured on 2026-10-02 in full Chromium 153.0.8010.12 (Playwright, channel 'chromium', one fresh browser context per Vary value) against a Node.js 24.21.0 server on localhost:8965-8967 that counted every request per URL and answered with Cache-Control: max-age=60 plus the Vary value under test. Each case used its own URL. The nginx snippet and the CDN notes are documentation, not executed. PlainHeaders is our extension; Node.js, Chromium and the HTTP headers tool are free.