frontend / browser internals / 08_caching_headers.md

HTTP Caching Headers — Cache-Control, ETag, Vary

7 min read source

HTTP Caching Headers — Cache-Control, ETag, Vary

TL;DR

Browser caching is governed by HTTP response headers. Cache-Control is the modern directive — max-age, s-maxage, no-cache, no-store, private, public, immutable, stale-while-revalidate. ETag + If-None-Match enables cheap revalidation (304 responses). Vary tells caches “this response varies by these request headers” — critical for Accept-Encoding, Accept-Language, content negotiation. The senior pain point: most “stale data” bugs are missing or wrong cache headers.

(Backend angle in ../../backend/12_protocols/http/03_http_semantics_and_caching.md. This file is frontend-perspective.)

Interview Q&A

Q: Cache-Control directives — the ones you need to know.

A:

Directive Meaning
max-age=N fresh for N seconds (browser cache)
s-maxage=N fresh for N seconds for shared caches (CDN); overrides max-age for them
no-cache may cache, but revalidate every time before serving — NOT “don’t cache”
no-store genuinely don’t cache (sensitive data)
private only browser may cache; not shared caches/CDN (per-user data)
public any cache may store
must-revalidate once stale, must revalidate; don’t serve stale on origin error
immutable won’t change for its lifetime — don’t even revalidate
stale-while-revalidate=N serve stale up to N seconds while fetching fresh in background
stale-if-error=N serve stale up to N seconds if origin errors

The most-confused pair: no-cache lets the response be cached but always revalidated. no-store actually disables caching. Mistaking them is the canonical caching bug.

Q: Common cache-control patterns.

A:

# Hashed static asset (URL changes per content)
Cache-Control: public, max-age=31536000, immutable
# = cache forever in browser + CDN, never revalidate

# HTML or API response that changes
Cache-Control: no-cache
# = may cache, but check with origin every time (cheap 304 revalidation)

# Per-user dashboard data
Cache-Control: private, no-cache
# = browser may cache, CDN must not (per-user); always revalidate

# Truly sensitive (auth token, financial data)
Cache-Control: no-store
# = never cache anywhere

# Tolerable staleness (feed cards, comments)
Cache-Control: max-age=300, stale-while-revalidate=86400
# = fresh 5 minutes, then serve stale up to 24h while refreshing in background

The private for per-user data is critical: missing it can leak user A’s response from a CDN to user B — a real security bug.

Q: ETag and conditional requests — how revalidation actually works.

A:

  1. Server sends response with ETag: "abc123" (a content fingerprint).
  2. Browser caches the response.
  3. On re-request, browser sends If-None-Match: "abc123".
  4. Server compares to current ETag:
    • Unchanged → 304 Not Modified with no body (tiny).
    • Changed → 200 OK with new body + new ETag.
# First request
GET /api/posts/1
→ 200 OK
  Cache-Control: no-cache
  ETag: "v1-abc123"
  ...

# Second request
GET /api/posts/1
If-None-Match: "v1-abc123"
→ 304 Not Modified
  ETag: "v1-abc123"

# After content changed
GET /api/posts/1
If-None-Match: "v1-abc123"
→ 200 OK
  ETag: "v1-def456"
  (new body)

A 304 is headers only — saves bandwidth. Combined with no-cache (revalidate every time), revalidation is cheap when content’s unchanged.

Last-Modified + If-Modified-Since is the timestamp-based equivalent; less precise (1s granularity, clock skew). Use ETag.

Q: Vary header — what for?

A: Tells caches: “this response depends on these request headers; cache per-variant.”

Cache-Control: public, max-age=300
Vary: Accept-Encoding, Accept-Language

This says: the same URL has different responses for different Accept-Encoding (compressed vs not) or Accept-Language (English vs Japanese). The cache stores separate variants per (URL, vary-header-combo).

Critical for Accept-Encoding (browsers send Accept-Encoding: gzip, br; cache must serve the right variant to the right client). Most CDNs include this automatically; some need explicit config.

Don’t Vary: * — disables caching effectively. Don’t include Cookie in Vary if you want any cache hits (per-cookie variants = per-user cache, useless).

Q: Service Worker cache vs HTTP cache vs app cache — how do they relate?

A: Layered:

Browser memory cache  →  Browser disk cache  →  Service Worker cache (Cache API)  →  Network (subject to CDN, server cache)

Cache-Control governs the HTTP cache layers. Service Worker’s Cache API is separate — you control it programmatically; HTTP headers don’t apply.

App caches (TanStack Query, SWR, RTK Query) are another layer, in-memory, owned by the app code. HTTP caching is below; app cache is above.

For an SPA fetching /api/x:

  1. Code calls fetch("/api/x").
  2. If SW is active, SW’s fetch handler runs first (can serve from Cache API).
  3. Otherwise, browser HTTP cache (per Cache-Control).
  4. Network.

App cache (TanStack Query) wraps the fetch; it sees what fetch returned (cached or not).

Q: CDN caching — how does Cache-Control apply?

A: CDNs honor Cache-Control:

  • public, max-age=3600 — CDN caches for 1 hour.
  • s-maxage=3600, max-age=60 — CDN caches for 1 hour; browser for 1 minute. Lets you push aggressive edge caching without burdening browsers.
  • private — CDN does NOT cache. Browser may.
  • no-store — neither.

For per-user responses (auth-gated): always private. For public marketing pages: public, s-maxage=.... Pair with Vary: Authorization if you want the CDN to cache differently per auth header (rarely useful).

Q: Cache-Control: immutable — when use?

A: For hashed asset filenames where the URL is unique per content:

/app.a1b2c3.js     ← URL changes when content changes

Since the URL is unique, the response can never become “stale” — the same URL always means the same content. immutable tells the browser “don’t even ask, don’t revalidate.”

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

The user revisits, browser serves from disk cache instantly without a conditional request. Bandwidth: 0.

Without immutable, the browser may still issue a conditional request (depending on cache state), wasting a round trip on something it has cached.

Q: stale-while-revalidate — show me.

A:

Cache-Control: max-age=60, stale-while-revalidate=86400

Behavior:

  • 0-60s: serve from cache (fresh).
  • 60s-86400s: serve from cache (stale) and kick off a background refresh.
  • 86400s+: must refresh before serving.

UX: feed loads instantly, refreshes silently. Used by Vercel/Next.js for ISR, by many news sites.

Works at every cache layer — browser, CDN, service worker. The browser respects it for fetch; the CDN respects it for edge serving.

Q: How do you invalidate a CDN cache?

A: Two strategies:

  1. Versioned URLs — hash in the filename (/app.a1b2c3.js). New version = new URL = no invalidation needed. The old URL stays cacheable; nobody requests it.
  2. Purge API — CDN provides an endpoint to purge specific URLs or tag groups. Slower (propagation takes seconds-minutes), but works for HTML / API responses you can’t version.

For static assets: always version. For HTML: short TTL + purge on deploy. For APIs: short TTL + purge on data change (via webhooks from your app to the CDN).

Cloudflare, Fastly, CloudFront all have purge APIs.

Q: What headers does the browser send on conditional requests?

A: Depending on what the cached response had:

  • If ETag was present: If-None-Match: "<etag>".
  • If Last-Modified was present: If-Modified-Since: <date>.
  • Both can be sent; server uses either.

The server responds with 304 Not Modified (no body) or 200 OK with the new body.

For caches with no validator (no ETag, no Last-Modified), the browser must do a full refetch on revalidation — no 304 possible.

Gotchas / edge cases

  • no-cache is misnamed — it means “revalidate before serving,” not “don’t cache.” no-store is “don’t cache.”
  • Missing private on per-user data can leak to other users via CDN. Real data-leak bug; happens.
  • Cache-Control: max-age=0 vs no-cache — practically equivalent (forces revalidation), but no-cache is the more explicit intent.
  • CDN ignoring Vary: Cookie — some CDNs don’t vary by Cookie, so anything Cookie-dependent caches incorrectly. Test with your CDN.
  • Cache-Control: public overriding private — if upstream and origin disagree, the order in the header matters. Don’t mix.
  • Expires header (legacy) — pre-Cache-Control. If both present, Cache-Control wins. Don’t set both.
  • Browser back/forward cache (bfcache) — separate from HTTP cache. Aggressive snapshot. Disabled by Cache-Control: no-store (and a few other things). Affects perceived “instant back” navigation.
  • Service Worker Cache API entries don’t expire automatically — you have to manage TTL yourself.

What a senior is expected to say

  • “Hashed assets: public, max-age=31536000, immutable — forever, no revalidation. HTML/API: no-cache + ETag for cheap revalidation. Per-user: private, no-cache. Sensitive: no-store.”
  • no-cache is ‘revalidate before serving,’ not ‘don’t cache.’ no-store is the actual ‘don’t cache.’ Confusing them is the canonical caching bug.”
  • ETag + If-None-Match304 Not Modified (headers only). Cheap revalidation for content that may or may not have changed.”
  • Vary: Accept-Encoding, Accept-Language so caches serve the right variant. Don’t Vary: * or Vary: Cookie (kills caching).”
  • private on per-user responses or you risk leaking via CDN.”
  • “Layered caches: browser memory → disk → SW Cache API → CDN → origin. Cache-Control governs HTTP layers; SW Cache API is separate; app caches (TanStack Query) wrap the whole thing.”

Cross-references

Further reading