One URL can return JSON to an API client and plain text to another reader. Content-Type identifies the selected representation; Vary identifies request fields relevant to selecting it. A correct body label cannot prevent a cache from choosing the wrong stored response when the request-matching information is missing.

Separate format, selection, and reuse

For negotiation driven by the request’s Accept field, return Vary: Accept alongside the actual response type. Vary: Content-Type would instead nominate the request’s Content-Type field, which does not describe the desired response. A cache cannot reconstruct the selection rule merely by inspecting the stored response’s media type. Its normal key already includes the target URI and relevant method semantics; Vary adds request-header matching requirements.

Compression introduces another distinction. Gzipped JSON still has the JSON media type, with Content-Encoding: gzip describing a content coding of the representation. This is not Transfer-Encoding or message framing. When the server selects that coding from Accept-Encoding, that request field is a relevant Vary dimension. Include only dimensions actually involved in selection; a browser’s physical viewport is not automatically a request header.

Vary neither grants permission to cache nor makes a stored response fresh. Storage and reuse must also satisfy status, method, authorization, and cache-directive rules. It is not an access-control boundary: adding Vary: Cookie does not establish that private account data is safe in a shared cache. Decide storage policy and audience independently, then describe the variation of representations that may legitimately be reused.

When it breaks

With an otherwise cacheable, fresh response, omitting Vary can let the first request determine what later readers receive. The origin works correctly when contacted directly, but a later client receives an incompatible representation without reaching it. A successful status and a perfectly honest Content-Type do not reveal that the response belongs to a different request preference. Compare the requested Accept value, returned type, and evidence of cache reuse.

Another failure is emitting Vary only for the nondefault representation. The default response then remains eligible for unrelated preferences. Generate consistent selection metadata for every representation of the route. When middleware adds a separate dimension, retain the existing dimensions instead of overwriting them. Large, unnecessary dimensions can fragment cache reuse, so investigate actual selection inputs rather than adding every available header.

Check it quickly

Use Python 3.11 or newer and a POSIX shell. These commands create a temporary environment and install the tested CacheControl and Requests versions. Installation uses the network; the following experiment talks only to loopback and disables environment proxy and netrc settings. These are experiment dependencies, not website dependencies.

vary_lab=$(mktemp -d)
python3 -m venv "$vary_lab/venv"
"$vary_lab/venv/bin/python" -m pip install CacheControl==0.14.4 requests==2.34.2

Run this program with "$vary_lab/venv/bin/python" in the same shell. The server recognizes exactly two Accept values; it is a fixture, not a general negotiator supporting wildcards or quality weights. CacheControl supplies the actual cache implementation.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread

import requests
from cachecontrol import CacheControl

representations = {
    "application/json": b'{"format":"json"}\n',
    "text/plain": b"format=plain\n",
}
hits = {"/vary": 0, "/no-vary": 0, "/star": 0}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        media = self.headers.get("Accept")
        if self.path not in hits or media not in representations:
            self.send_error(400)
            return
        hits[self.path] += 1
        body = representations[media]
        self.send_response(200)
        self.send_header("Content-Type", media)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "public, max-age=300")
        if self.path != "/no-vary":
            self.send_header("Vary", "*" if self.path == "/star" else "Accept")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = Thread(target=server.serve_forever)
worker.start()
session = CacheControl(requests.Session())
session.trust_env = False
try:
    base = f"http://127.0.0.1:{server.server_port}"
    for route in hits:
        for index, accept in enumerate([
            "application/json", "application/json", "text/plain", "text/plain"
        ]):
            response = session.get(base + route, headers={"Accept": accept}, timeout=3)
            response.raise_for_status()
            expected = "application/json" if route == "/no-vary" else accept
            assert response.headers["Content-Type"] == expected
            assert response.content == representations[expected]
            cached = index > 0 if route == "/no-vary" else index in (1, 3)
            if route == "/star":
                cached = False
            assert response.from_cache == cached
            print(route, accept, "->", expected, "cached=", response.from_cache)
    assert hits == {"/vary": 2, "/no-vary": 1, "/star": 4}
    print("origin hits:", hits)
finally:
    session.close()
    server.shutdown()
    server.server_close()
    worker.join()

For /vary, cache flags are false, true, false, true: each preference reaches the origin once and its immediate repeat is cached. For /no-vary, flags are false, true, true, true; both plain-text requests incorrectly receive JSON. The assertions deliberately verify this failure rather than pretending it is acceptable application behavior. Origin counts are respectively two and one, independently confirming which requests reached the server.

For /star, all four requests reach the origin. A Vary value containing * always fails cache matching; the standard does not equate that rule with a universal prohibition on storage. This CacheControl version declines to store those responses. The test also does not assert that a cache keeps multiple variants simultaneously: an implementation can replace its single entry and still respect matching requirements.

Run requests in both preference orders when testing a deployed cache, using an isolated test URL or approved invalidation procedure. A newly corrected origin header does not automatically repair an already fresh, incorrectly keyed entry. Check the real proxy or CDN separately: this experiment establishes the mechanism in a client cache, not the configuration or privacy guarantees of your production shared cache.

Further reading

Sources