A well-cached site loads the second visit almost instantly, with a tenth of the CDN cost and origin load. A poorly cached site shows users stale content for hours — or never caches anything and hits the origin on every request. This guide covers what each Cache-Control directive actually means, how ETag and Last-Modified work, the split between CDN and browser caches, and the right header combo for each asset type.
Two cache layers — browser vs shared (CDN)
The same response can be cached in two places.
- Private (browser) — one user. Responses with
Authorizationdefault to private. - Shared (CDN, proxy) — multiple users. Use
publicto allow.
Some Cache-Control directives apply to both (max-age), some only to shared caches (s-maxage).
Cache-Control directives at a glance
| Directive | Meaning | Example |
|---|---|---|
max-age=N | Fresh for N seconds in all caches | max-age=3600 (1 hour) |
s-maxage=N | max-age override for shared caches only | s-maxage=86400 |
public | Storable in shared caches | public, max-age=300 |
private | Browsers only — never shared | private, max-age=0 |
no-cache | Store, but validate with origin on every request (may return 304) | no-cache |
no-store | Don't store at all | no-store (sensitive data) |
immutable | While fresh, never try to revalidate. Content never changes. | max-age=31536000, immutable |
stale-while-revalidate=N | After expiry, serve stale for N seconds while revalidating in background | max-age=60, stale-while-revalidate=600 |
must-revalidate | Forbid stale after expiry | max-age=0, must-revalidate |
Recipes per asset
Hashed static assets (JS/CSS/images)
Cache-Control: public, max-age=31536000, immutableFile names include a content hash (app.a3f9.js), so a content change forces a new file name. One year + immutable. Next.js, Vite, and webpack apply this automatically to hashed assets.
HTML (SPA index, SSR pages)
Cache-Control: no-cacheOne URL must always point to the latest content. With no-cache, every request revalidates — a matching ETag returns 304 with no body. Don't confuse it with no-store, which forbids storage entirely.
API responses (per-user data)
Cache-Control: private, no-cacheCDN must not cache (risk of cross-user leakage). The browser may cache + revalidate.
API responses (public, slow-changing)
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600Browser one minute, CDN five minutes, ten minutes of stale allowed after expiry. Origin load drops sharply with traffic.
Sensitive responses (accounts, payments, auth)
Cache-Control: no-storeNever store. Even the browser's back navigation should refetch.
ETag — conditional GET
ETag is a hash (or version id) of the response body. The server attaches it; the browser sends it back in If-None-Match. If unchanged, the server returns 304 with no body.
# First request
HTTP/1.1 200 OK
ETag: "33a64df5"
Cache-Control: no-cache
Content-Type: text/html
<html>...</html>
# Second request
GET /index.html
If-None-Match: "33a64df5"
# Unchanged
HTTP/1.1 304 Not Modified
ETag: "33a64df5"304 has no body — network savings. no-cache + ETag is the canonical combo for SPA index.html. See what 304 means in HTTP Status Codes.
Strong vs weak ETag
- Strong:
"abc123"— byte-for-byte identical. - Weak:
W/"abc123"— semantically identical (whitespace differences allowed).
Strong is the default. Weak is used by transforms like gzip/brotli.
Last-Modified — time-based validation
The alternative to ETag. The body's last change time (HTTP date).
HTTP/1.1 200 OK
Last-Modified: Wed, 15 May 2026 14:30:00 GMT
Cache-Control: no-cache
# Next request
GET /image.jpg
If-Modified-Since: Wed, 15 May 2026 14:30:00 GMT
# Unchanged
HTTP/1.1 304 Not ModifiedCompared to ETag:
- ETag — sub-second precision and exact equality. Preferred.
- Last-Modified — second granularity. Two changes in the same second collide.
- Both — common; clients can use either.
CDN specifics — Vary, s-maxage
Vary header
Same URL returns different responses based on request headers → the CDN splits the cache per header value.
Vary: Accept-Encoding
# split per gzip / brotli
Vary: Accept-Language
# split per language
Vary: Cookie
# split per cookie — almost always bad (one cache key per user = no CDN benefit)Vary: Cookie shreds the cache per user, defeating the CDN. Use private for authenticated data instead.
Cloudflare Cache Tag
The Cache-Tag header on Cloudflare Workers / Pages enables tag-based purging. Cache-Tag: product-42, category-shoes — when the API changes, purge by tag.
stale-while-revalidate — zero-wait refresh
Right after expiry, serve the stale response immediately and revalidate in the background. Users never wait.
Cache-Control: max-age=60, stale-while-revalidate=600
# T=0 response cached
# T=30 fresh, served from cache
# T=60 expiry begins
# T=90 stale served + background revalidate → new response cached
# T=120 fresh againSupported by Vercel and Cloudflare. The standard pattern in Next.js Route Handlers and Server Components. Great fit for search results, dashboards, and most SSR responses.
Common pitfalls
1. Confusing no-cache with no-store
no-cache = store + revalidate every time. no-store = don't store. Sensitive data uses no-store; "always fresh" content uses no-cache.
2. no-store on every response
Over-defensive. Static images and CSS with no-store make the whole site slow. Different headers per asset.
3. max-age=0, must-revalidate = no-cache?
Practically yes. Both forms are common.
4. Unbounded CDN cache
Without s-maxage, the CDN uses max-age. Often you want the CDN to cache longer than the browser — split the values.
5. Caching a response with Set-Cookie
Responses with Set-Cookie default to private. Explicitly marking them public can leak one user's cookie to another — a serious incident. Never make auth responses public.
6. Forgetting to purge the CDN
Origin updates with the CDN still serving the old version. Wire CDN purges into your deploy pipeline, or use cache-busting URLs (?v=...).
7. Using Pragma: no-cache
HTTP/1.0 legacy. Modern clients ignore it. Don't bother.
Debugging — see the headers
curl -I https://example.com/api/users
# Cache-Control: public, max-age=60
# ETag: "abc123"
# Vary: Accept-Encoding
# CDN hit/miss (Cloudflare)
curl -I https://example.com/asset.js
# CF-Cache-Status: HIT (or MISS / EXPIRED / DYNAMIC)Build curl commands with method/header combinations in cURL Builder and add -I to see headers only. Use URL Parser to split URLs and compare behavior across paths.
Summary
- Hashed static assets =
max-age=31536000, immutable. One year. - HTML =
no-cache+ ETag. Revalidate every time, skip the body. - Public APIs =
public, max-age + s-maxage + stale-while-revalidate. Lean on the CDN. - Sensitive APIs =
private, no-cacheorno-store. - ETag is the standard. Last-Modified is the fallback.
- Avoid
Vary: Cookie— it defeats caching.