Vary: Accept-Encoding

Last reviewed on 2026-04-28

How the Vary header keeps caches from serving the wrong body — and how to keep cache hit rates healthy when you set it.

Why Vary exists

HTTP caching is keyed by URL. That works as long as a given URL has exactly one canonical response. The moment a server starts producing different responses for the same URL based on a request header — for example, a gzip body for one client and a Brotli body for another — the URL alone is no longer enough to identify a cache entry. The Vary response header solves that problem by adding the named request headers to the cache key.

Vary: Accept-Encoding tells every cache between the origin and the client that the response body is determined not just by the URL but by the value of the Accept-Encoding header on the original request. A cache that respects Vary will store one entry per (URL, encoding) pair and only return a hit when both match.

What goes wrong without it

Imagine a shared corporate proxy in front of two browsers. Browser A sends Accept-Encoding: br, gzip and gets back a Brotli-compressed body. The proxy caches that response under the URL. Browser B then requests the same URL, sending only Accept-Encoding: gzip. With no Vary header on the cached response, the proxy is free to return the Brotli body from cache. Browser B sees garbled bytes and a decoding error, because br was never on its accept list.

The same failure mode happens inside a CDN, inside a reverse-proxy cache like Varnish, and inside the browser's own disk cache when a user navigates away from HTTPS and the original response carried no Vary. The fix in every case is the same: every response whose body depends on Accept-Encoding must include Vary: Accept-Encoding.

What goes wrong with it — the cache-key explosion

A naive cache that respects Vary stores one entry per literal value of the varied header. There are far more distinct Accept-Encoding strings in the wild than you would expect. A small sample, all functionally equivalent for a server that supports gzip and Brotli:

Accept-Encoding: gzip, deflate, br
Accept-Encoding: br, gzip, deflate
Accept-Encoding: gzip,deflate,br
Accept-Encoding: gzip, deflate, br, zstd
Accept-Encoding: br;q=1.0, gzip;q=0.8, *;q=0.1
Accept-Encoding: identity, gzip, deflate, br

Each of these is the same logical request. A cache that keys on the literal string treats them as six different cache entries and rebuilds the response six times. On a busy site that means an order-of-magnitude drop in hit rate and a corresponding spike in origin compression CPU. This is the most common reason a freshly-enabled Vary: Accept-Encoding appears to make a site slower instead of faster.

Normalising at the edge

The fix is to normalise Accept-Encoding to a canonical form before the cache key is computed. Every major CDN and reverse proxy has either a built-in setting or a snippet for this. The canonical form usually collapses to one of three or four buckets:

  • br — if the request accepts Brotli on a secure origin.
  • zstd — if the request accepts Zstandard.
  • gzip — if the request accepts gzip but not Brotli/Zstd.
  • identity — everything else.

With normalisation in place, the cache stores at most three or four variants per URL instead of dozens, and the hit rate stabilises. The dedicated CDN guide shows the per-vendor mechanics; the relevant snippets for major edges are short and well-documented.

Nginx: normalise before cache lookup

map $http_accept_encoding $normalized_ae {
    "~*\bbr\b"   "br";
    "~*\bzstd\b" "zstd";
    "~*\bgzip\b" "gzip";
    default      "identity";
}

proxy_cache_key "$scheme$proxy_host$request_uri:$normalized_ae";

Adding multiple values to Vary

It is common to vary on more than one header — for example, Vary: Accept-Encoding, Accept-Language on a site that serves localised content. The values are listed comma-separated. Each varied header multiplies the effective cache-entry count, so the same normalisation discipline applies to every header in the list. Sites that vary on User-Agent in particular need to be careful: there are millions of distinct User-Agent strings in the wild, and an unnormalised Vary: User-Agent reduces a cache to almost zero hit rate.

The Cache-Control interaction

A response with Cache-Control: private is intended for a single user's cache and is not stored by shared intermediaries; Vary still matters because the user's own browser cache will key on it. A response with Cache-Control: no-store bypasses caches entirely; Vary is harmless but irrelevant. The interesting case is Cache-Control: public, max-age=..., which is the cache regime where Vary: Accept-Encoding earns its keep.

One subtle interaction: some CDNs treat the absence of Vary on a compressed response as a configuration error and add the header automatically before caching. This is generally helpful, but it makes it harder to reason about behaviour during testing — if you removed Vary at the origin and still see correct per-encoding splits at the edge, the edge is probably injecting it for you.

Decision checklist

  1. If the response body changes based on Accept-Encoding, set Vary: Accept-Encoding. Always. There is no situation where compressing the body but omitting the header is correct.
  2. If you are running a shared cache in front of an origin that compresses on the fly, normalise Accept-Encoding before computing the cache key.
  3. If you precompress static assets and your server supports it (gzip_static, brotli_static), the server will set Vary for you on those responses. See the precompression guide.
  4. If you observe a high cache miss rate after enabling Brotli, check whether your edge is normalising. The miss rate is the symptom; cache-key explosion is almost always the cause.
  5. If you operate a CDN bypass for some routes (e.g. direct-to-origin admin paths), make sure the origin still emits Vary — the browser's own cache needs it just as much as the CDN does.

Common mistakes

  • Setting Vary: *. The wildcard form means "this response varies on something I am not telling you," which forces shared caches to treat the response as uncacheable. Only use it if the response really cannot be cached.
  • Forgetting Vary on error pages. A cached gzip-encoded 404 served to a Brotli-only client will fail to decode, just like a 200 would. Error responses need the same header treatment as success responses.
  • Conflicting Vary headers across the chain. If the origin sets Vary: Accept-Encoding but a middleware overwrites it with Vary: Accept, the cache will key on the wrong header. Audit every layer that touches response headers.
  • Trusting a single browser test. Browsers with a warm cache may not re-send the request, so a misconfigured Vary can hide for hours during local testing. Use a fresh private window or a direct curl against the public URL when verifying.

Further reading

For a refresher on the request side, the Accept-Encoding header page covers syntax and q-values. For the per-server configuration that emits Vary automatically, see the Nginx, Apache, and IIS guides. The performance optimisation page covers the cache-hit-rate dynamics from the angle of CPU sizing.