Cache headers and cache keys

The headers that decide how long the edge holds a copy, and what makes two requests share one entry.

The headers that matter at the edge

# an asset whose name changes with its content
Cache-Control: public, max-age=31536000, immutable

# HTML: browsers revalidate, the edge holds a copy for an hour
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=60

# anything user-specific
Cache-Control: private, no-store

# API data that may be briefly stale under load
Cache-Control: public, max-age=30, s-maxage=300, stale-while-revalidate=120
DirectiveEffect at the edge
max-ageFreshness for browsers and shared caches
s-maxageOverrides max-age for shared caches only - your CDN knob
public / privateWhether a shared cache may store the response at all
no-storeNever written to any cache
no-cacheStored, but must be revalidated before every use
must-revalidateNo stale serving once expired
stale-while-revalidateServe stale while refreshing in the background
stale-if-errorServe stale if the origin errors - a real uptime win

Provider dashboards often override these headers with their own TTL settings. Find out which one wins before debugging: a header saying max-age=60 is useless if the panel is set to a 30-day edge TTL.

What makes up a cache key

A cache key is whatever the edge uses to decide "is this the same request?". By default it is host, path and query string. Vendors add options like the scheme, specific query parameters, device class, or selected headers. Everything you do not include means two different responses share one entry - and the wrong one gets served.

  • Query parameters: often pure tracking noise. Ignoring selected ones (utm_*, fbclid) raises the hit rate safely.
  • Cookies: including all cookies destroys the hit rate; excluding cookies that personalise the response leaks one user's page to another.
  • Vary tells caches which request headers matter. Set Vary: Accept-Encoding at minimum; add Vary: Accept-Language only if you really serve different bodies per language.
  • Device and geography: only key on them if the response genuinely differs.
  • Never vary on User-Agent as a whole - it explodes the key space into thousands of variants.
Vary: Accept-Encoding, Accept-Language
Cache-Key: host + path + country + normalized-query

# in the CDN configuration, the equivalent rules look like:
#   ignore query string params: utm_*, gclid, fbclid, msclkid
#   key on: path, country (only for /pricing/*)
#   never key on: cookie _session for /assets/*
⚠️
A response that includes Set-Cookie or was generated for a logged-in user must never land in a shared cache. If it does, one visitor sees another's data - and search engines can index the leak.

Verifying from the outside

  1. Request the URL twice and compare Age: a rising value means you are being served from cache.
  2. Send the same URL with and without a tracking parameter; they should return the same Age if you normalise query strings.
  3. Request a logged-in page with a fresh cookie jar and confirm the response says private or no-store.
  4. Check the same asset from another region if your provider exposes a network map - a low hit rate there means the key or TTL differs per location.
for i in 1 2; do
  curl -sI "https://example.com/?utm_source=test" | grep -i -E "^(age|cache-control|x-cache)"
  sleep 2
done

# strip the tracking parameter and confirm the same cached object
curl -sI "https://example.com/" | grep -i -E "^(age|x-cache)"

FAQ

Why does the edge ignore my Cache-Control header?
Usually a provider-level TTL rule that overrides origin headers, or the response status is not cacheable by default. Check both the panel rule and the status code.
Should I cache HTML at the edge?
Yes, with a short s-maxage and stale-while-revalidate. It gives most of the speed benefit while keeping deploys visible within minutes.

How a CDN serves your content Invalidation, purging and what not to cache

Last refreshed 2026-09-18.