Back to Networking
Networking
medium
mid

How does HTTP caching work with Cache-Control and ETag?

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.

7 min read·~12 min to think through

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.

ts
Cache-Control: public, max-age=31536000, immutable

Common 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-revalidate or s-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.

ts
# 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 Modified

Last-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.

ts
Vary: Accept-Encoding, Accept-Language

Tells 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.

  1. Browser checks its cache. If Cache-Control: max-age is still fresh → served from cache, zero network.
  2. If stale (or no max-age), browser revalidates: GET with If-None-Match.
  3. CDN may answer (if it has a fresh copy) or forward to origin.
  4. Origin compares ETag → 304 (no body) or 200 (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-cache and skips cache entirely.
  • immutable resources skip revalidation even on regular reload.

API caching gotchas.

  • POST/PUT/DELETE typically aren't cached (some CDNs allow it explicitly).
  • Cookies + caching → use private or no-store for 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

http
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "a3f9b1"
Content-Type: application/javascript
Hashed asset — cache forever
http
HTTP/1.1 200 OK
Cache-Control: public, s-maxage=60, stale-while-revalidate=600
ETag: "v3-abc123"
Vary: Accept-Encoding
API endpoint — short cache + SWR

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.

Senior engineer discussion

Senior signal: distinguishing freshness vs revalidation, using stale-while-revalidate, getting Vary right, and pairing HTTP cache with service workers.

Related questions