correctness

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 Accept values are bounded (just text/markdown and text/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 Vary correctly, but cache-warming tools should be sent with the right Accept header or they’ll populate the wrong representation.
  • Cache Rules let you explicitly set the cache key. Don’t include the raw Accept header — that’s a cache-busting vector (see Accept normalization). Instead, pair a Transform Rule that canonicalizes Accept to html or md with 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 = N in 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" → set Accept: md. Else set Accept: 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, but curl shows 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.