Updated April 2026
CDN Cache Headers Guide — Cache-Control, Vary, Age, X-Cache Explained
Quick Answer
CDNs cache responses based on Cache-Control: public, max-age=N. Check X-Cache: HIT to confirm the CDN is serving from cache. Use Vary: Accept-Encoding to cache separate versions for gzip and brotli. Use CDN-Cache-Control or Surrogate-Control to set different TTLs for CDN vs browser.
CDNs don't just cache — they interpret a specific set of headers to decide what to cache, for how long, and how to key the cache. Understanding these headers lets you control CDN behaviour precisely.
Audit your live CDN cache headers → EdgeFixThe headers CDNs read
| Header | Who sets it | What it controls |
|---|---|---|
Cache-Control | Your server | Primary caching instruction for both browser and CDN |
CDN-Cache-Control | Your server | CDN-only override — browsers ignore this |
Surrogate-Control | Your server | Varnish/Fastly CDN-only TTL (stripped before browser) |
Vary | Your server | Cache key dimensions — cache separate copies per Vary value |
ETag | Your server | Fingerprint for conditional requests |
Age | CDN | Seconds since CDN cached the response |
X-Cache | CDN | HIT = served from CDN cache, MISS = fetched from origin |
Check if your CDN is caching
curl -sI https://example.com/app.js | grep -i "cache\|age\|x-cache" # Good output: # Cache-Control: public, max-age=31536000, immutable # Age: 3600 ← been in CDN cache for 1 hour # X-Cache: HIT ← served from CDN, not origin # Bad output: # Cache-Control: no-store # X-Cache: MISS ← hitting origin every time
Set different TTLs for CDN vs browser
# CDN-Cache-Control — CDN caches for 1 hour, browser for 5 minutes Cache-Control: public, max-age=300 CDN-Cache-Control: max-age=3600 # Surrogate-Control (Fastly/Varnish) — same idea Surrogate-Control: max-age=3600 Cache-Control: max-age=300
The Vary header — cache key dimensions
# Cache separate versions for gzip vs brotli Vary: Accept-Encoding # Without Vary: Accept-Encoding, CDN serves gzip to browsers expecting brotli # Always set Vary: Accept-Encoding on compressed responses # Bad: varies by every header (effectively disables caching at CDN) Vary: * Vary: Cookie # CDN caches separate copy per cookie — cache pollution
Cloudflare-specific headers
cf-cache-status: HIT # Served from Cloudflare edge cf-cache-status: MISS # Fetched from origin cf-cache-status: EXPIRED # Was cached, expired, refetching cf-cache-status: BYPASS # Cache bypassed (e.g. Cookie present) cf-cache-status: DYNAMIC # Cloudflare won't cache this response type cf-ray: 7a3b4c5d6e7f8g9h # Request ID for debugging
Vercel-specific headers
x-vercel-cache: HIT # Served from Vercel edge network x-vercel-cache: MISS # Fetched from your function/origin x-vercel-id: sfo1::... # Edge region + request ID
Why responses bypass CDN cache
Cache-Control: privateorno-store— CDN respects thisSet-Cookiein response — Cloudflare bypasses cache by defaultAuthorizationheader in request — CDN treats as privateVary: Cookie— creates per-cookie cache entries- POST, PUT, DELETE requests — not cached by CDNs