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
- Content-Type should describe the bytes you actually send
- Cache headers are a contract with two clocks
Sources
- RFC 9110: Vary, selection fields and representation metadata, checked 2026-09-07.
- RFC 9111: Vary cache matching, matching requirements, wildcard behavior, and storage rules, checked 2026-09-07.
- CacheControl 0.14.4, the Requests caching wrapper used in the experiment, checked 2026-09-07.