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. You can add Accept to the cache key manually:
    • Cache Rules → Custom cache key → Include header: Accept
  • 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 Accept to the cache key. For Nginx:

proxy_cache_key "$scheme$request_method$host$request_uri$http_accept";

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.