HTTP caching — Cache-Control, ETag, and the validation flow
Cache-Control directs *how long* responses are cached. ETag/Last-Modified enable *revalidation* — client sends If-None-Match, server returns 304 if unchanged. Combine: long max-age for hashed assets, short max-age + revalidation for HTML/API.
Browsers and CDNs cache responses to skip redundant network work. The headers that govern this — Cache-Control, ETag, Last-Modified, Vary — work together. Get them wrong and either users see stale content or you waste bandwidth.
Cache-Control: how long, and who can cache.
Cache-Control: public, max-age=31536000, immutableCommon directives:
public— any cache (browser, CDN, proxy) may store.private— only the user's browser.max-age=N— fresh for N seconds. While fresh, the cached copy is served without contacting the server.s-maxage=N— like max-age but for shared caches (CDN). Lets you cache longer at the edge than in browsers.no-cache— cache may store but must revalidate before serving. Misleading name; "store-and-revalidate" would be accurate.no-store— never cache. Use for sensitive data (auth tokens, banking pages).must-revalidate— once stale, must check with origin (no serving stale on errors).stale-while-revalidate=N— serve stale up to N seconds while revalidating in the background. Big perceived-perf win.stale-if-error=N— serve stale on origin failure.immutable— promise the resource will never change. Browser skips revalidation even on reload.
Strong patterns.
- Hashed static assets (
/app.a3f9b1.js):Cache-Control: public, max-age=31536000, immutable. Forever-cacheable; new versions deploy under new URLs. - HTML / API:
Cache-Control: public, max-age=0, must-revalidateors-maxage=60, stale-while-revalidate=600. Revalidate on every request, but allow brief CDN caching to absorb traffic spikes. - User-specific:
Cache-Control: private, no-cache. Don't let CDN cache it.
ETag — the validation token.
The server includes an ETag (entity tag) in the response — a hash or version of the body. On revalidation, the browser sends If-None-Match: <etag>. If unchanged, the server returns 304 Not Modified with no body. Saves bandwidth (no body re-download) but still costs the round trip.
# Initial response
HTTP/1.1 200 OK
ETag: "v3-abc123"
Cache-Control: max-age=60
# Revalidation
GET /api/data
If-None-Match: "v3-abc123"
# Server compares; nothing changed:
HTTP/1.1 304 Not ModifiedLast-Modified — the older version of the same idea. Server sends Last-Modified: <date>; client sends If-Modified-Since: <date>. ETag is preferred (more precise — content can change without timestamp changing on copy operations).
Vary — cache key includes these headers.
Vary: Accept-Encoding, Accept-LanguageTells caches "store separate entries per value of these headers." Critical when you serve different content to different clients (gzipped vs not, English vs Spanish, AVIF vs JPEG). Forgetting Vary: Accept-Encoding is a classic CDN bug — gzipped content served to clients that didn't send Accept-Encoding: gzip.
Avoid Vary: User-Agent — the cardinality of UAs is enormous; you essentially defeat the cache.
The full flow on a repeat visit.
- Browser checks its cache. If
Cache-Control: max-ageis still fresh → served from cache, zero network. - If stale (or no max-age), browser revalidates:
GETwithIf-None-Match. - CDN may answer (if it has a fresh copy) or forward to origin.
- Origin compares ETag →
304(no body) or200(new body + new ETag).
Reload behavior.
- Normal navigation: respects
Cache-Control. - Reload (Ctrl/Cmd-R): adds
Cache-Control: max-age=0→ forces revalidation but uses cached body if 304. - Hard reload (Ctrl/Cmd-Shift-R): adds
Cache-Control: no-cacheand skips cache entirely. immutableresources skip revalidation even on regular reload.
API caching gotchas.
- POST/PUT/DELETE typically aren't cached (some CDNs allow it explicitly).
- Cookies + caching → use
privateorno-storefor personalized responses. - Auth headers can be cached if you set the headers right; usually not what you want.
CDN-specific. Cloudflare, Fastly, Vercel let you set s-maxage separately and respect stale-while-revalidate. Vercel's unstable_cache and Next.js fetch revalidation tags layer on top of these.
Service Worker is parallel. A SW intercepts before HTTP cache and can implement its own strategies (cache-first, network-first, stale-while-revalidate via the Cache API). Use SW for offline; HTTP caching for online perf.
Code
Follow-up questions
- •What's the difference between no-cache and no-store?
- •When would you use stale-while-revalidate?
- •Why prefer ETag over Last-Modified?
- •How do you invalidate a CDN cache for a specific URL?
Common mistakes
- •Setting Cache-Control: no-cache thinking it means 'never cache' (it means 'always revalidate').
- •Forgetting Vary: Accept-Encoding — gzipped content served to non-gzip clients.
- •Caching API responses with cookies but no `private` directive — leaks across users on a shared cache.
- •Long max-age on non-hashed HTML — users stuck on old versions for hours.
Performance considerations
- •stale-while-revalidate gives instant responses for repeat visits with background freshness.
- •Hashed assets + immutable + long max-age = 99% cache hit rate.
- •Avoid Vary headers with high cardinality — every variant is a separate cache entry.
Edge cases
- •ETag with multiple servers behind a load balancer — must generate consistent ETags or revalidations 200 instead of 304.
- •no-store still allows the response to be in browser memory until the page navigates.
- •Reverse proxies may strip or normalize Cache-Control — verify what the client receives.
Real-world examples
- •Vercel's edge cache + ISR uses s-maxage and tags to invalidate on demand.
- •Cloudflare APO ships HTML through Workers with its own cache rules.