Caching & CDN setup
Vary: Accept is necessary but not always sufficient. Cloudflare, Fastly, and Vercel each have quirks you need to know before production.
Setting Vary: Accept is the baseline for content negotiation through
caches. Most CDNs honor it — but “honor” varies, and some behaviors
need explicit configuration. Here’s what to check.
Cloudflare
Cloudflare’s default edge cache respects Vary: Accept on cacheable
responses. But a few gotchas:
- Varying on more than a small set of values is discouraged. If
your
Acceptvalues are bounded (justtext/markdownandtext/html), you’re fine. If you accept wild variety, Cloudflare may decline to cache. - Transform Rules can override. If you’ve enabled HTML minification,
compression tweaks, or email obfuscation, verify they don’t strip
Vary. - Tiered Cache and Smart Tiered Cache honor
Varycorrectly, but cache-warming tools should be sent with the rightAcceptheader or they’ll populate the wrong representation. - Cache Rules let you explicitly set the cache key. Don’t include
the raw
Acceptheader — that’s a cache-busting vector (see Accept normalization). Instead, pair a Transform Rule that canonicalizesAccepttohtmlormdwith a Cache Rule that includes the normalized header, or drive the cache key from a Worker. - Markdown for Agents (Cloudflare’s managed feature) handles all of this automatically. See the guide.
Fastly
Fastly’s edge cache honors Vary: Accept per HTTP spec. If your origin
returns the header, Fastly keys the cache on Accept automatically —
no custom VCL required. See
Fastly’s Vary documentation.
Custom VCL is only needed if you want the edge to add a Vary header the origin forgot, or to change the cache-key shape beyond what Vary gives you. For append-at-edge:
sub vcl_fetch {
if (beresp.http.Vary) {
set beresp.http.Vary = beresp.http.Vary ", Accept";
} else {
set beresp.http.Vary = "Accept";
}
}
Vercel
Vercel’s edge cache respects Vary: Accept on Cache-Control: s-maxage=...
responses. For Next.js App Router:
export const revalidate = Nin a route — respects Vary.- Route handlers that return
Cache-Control: s-maxage=60+Vary: Accept— cached per-variant at the edge. - ISR (
getStaticProps+revalidate) — Vary works, but you need to trigger regeneration for both variants.
Vercel’s dashboard shows cache hits per variant; check that both
Markdown and HTML requests get x-vercel-cache: HIT after warmup.
Netlify
Netlify Edge respects Vary: Accept via the standard Cache-Control
semantics. The Netlify-specific quirk: if you’re using Netlify Functions
or Edge Functions, ensure the function itself returns the Vary header —
don’t rely on _headers file rules for dynamic responses.
Origin-level caching
If your own reverse proxy is doing the caching (not a CDN in front of
it), add a normalized Accept value to the cache key — never the raw
header. Keying on the raw header lets a client bust your cache by
sending garbage mime types (Accept: text/html, nonsense/foo-{random})
and forcing origin hits on every request.
See Accept normalization below for the Nginx, Varnish, and Cloudflare patterns.
Accept normalization
Vary: Accept tells the cache that the response depends on Accept.
It doesn’t tell the cache how to compare two Accept values. Most
CDNs, by default, treat the raw header string as the cache key
component — so two requests with different Accept values produce
two cache entries, even if they’d receive the exact same response.
That’s both inefficient (unbounded cache fragmentation) and a DoS
vector: a malicious client can send a unique Accept on every request
and force origin hits indefinitely.
The fix is to normalize Accept to the representation you actually
serve before it hits the cache key. For this site’s two-representation
setup, that’s a two-value bucket: html or md.
Nginx
map $http_accept $accept_bucket {
~*text/markdown "md";
default "html";
}
proxy_cache_key "$scheme$request_method$host$request_uri$accept_bucket";
Varnish / Fastly VCL
sub vcl_hash {
if (req.http.Accept ~ "text/markdown") {
hash_data("md");
} else {
hash_data("html");
}
# Don't hash_data(req.http.Accept) directly — that's the cache-busting
# vector. Only hash the canonicalized bucket.
}
Cloudflare Workers
The Cache API keys on the request URL by default. Embed the negotiated
representation in a synthetic cache-key URL so Vary: Accept effectively
collapses to two entries per URL:
const negotiated = pickAccept(request.headers.get('Accept')); // "html" | "md"
const cacheKeyUrl = new URL(request.url);
cacheKeyUrl.searchParams.set('__repr', negotiated);
const cacheKey = new Request(cacheKeyUrl.toString(), {
method: 'GET',
headers: request.headers,
});
const cached = await caches.default.match(cacheKey);
if (cached) return cached;
const response = await fetch(request); // origin
await caches.default.put(cacheKey, response.clone());
return response;
Cloudflare Cache Rules (no Worker)
If you can’t run a Worker, use a Transform Rule to rewrite Accept to
a canonical value before the cache lookup:
- Transform Rule (request header modification) — match
http.request.headers["accept"] contains "text/markdown"→ setAccept: md. Else setAccept: html. - Cache Rule → Custom cache key → Include header: Accept — the header is now one of two canonical values, so cache keys collapse.
Apache
Apache’s native mod_cache doesn’t expose a clean way to normalize
request headers before keying. The realistic setup is to put Nginx,
Varnish, or a CDN in front as the cache layer and leave Apache as the
origin.
Why not just drop Vary: Accept?
Vary: Accept is still required — it tells shared caches (browsers,
intermediate proxies, CDNs without custom cache keys) that the
response depends on Accept. Without it, the first response cached
for a URL gets served to every subsequent request regardless of
representation. Normalization is about keying efficiently; Vary
is about keying at all.
Testing your cache
# Prime the cache with Markdown
curl -sI -H "Accept: text/markdown" https://yoursite.com/article
# Then with HTML — should get a distinct response (new cache entry)
curl -sI -H "Accept: text/html" https://yoursite.com/article
# Then request Markdown again — should be a cache HIT now
curl -sI -H "Accept: text/markdown" https://yoursite.com/article
Check for age:, x-cache:, or cf-cache-status: headers to confirm
which request was served from cache. If all three come from origin,
your cache isn’t using Vary correctly.
The “forgot Vary entirely” failure mode
No Vary: Accept on a negotiated response is the single most common
production bug. Symptoms:
- Agents report they’re getting HTML with
Content-Type: text/html, butcurlshows the Markdown is being served correctly - Users in a browser see raw Markdown on a page that should be HTML
- Cache hit rates look normal; the cache is just serving the first-seen representation to everyone
Fix: add Vary: Accept to every negotiated response, and purge your
cache. Until you purge, cached entries are still keyed on URL alone and
will keep serving the wrong thing.