Frontend / Browser internals / 08_caching_headers.md

HTTP Caching Headers — Cache-Control, ETag, Vary

Updated 6 interview angles 8 min read source
On this page14
  1. Cache-Control directives — the ones you need to know.
  2. Common cache-control patterns.
  3. ETag and conditional requests — how revalidation actually works.
  4. Vary header — what for?
  5. Service Worker cache vs HTTP cache vs app cache — how do they relate?
  6. CDN caching — how does Cache-Control apply?
  7. Cache-Control: immutable — when use?
  8. stale-while-revalidate — show me.
  9. How do you invalidate a CDN cache?
  10. What headers does the browser send on conditional requests?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

HTTP Caching Headers — Cache-Control, ETag, Vary

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 HTTP semantics and caching. This file is frontend-perspective.)

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

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.

Common cache-control patterns.

http
# 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.

ETag and conditional requests — how revalidation actually works.

  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.
http
# 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.

Vary header — what for?

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

http
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).

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

Layered:

text
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).

CDN caching — how does Cache-Control apply?

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=.... Do not reach for Vary: Authorization: a shared cache must not store a response to an Authorization request at all unless it is marked public or s-maxage (RFC 9111), and per-user content belongs in private.

Cache-Control: immutable — when use?

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

text
/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.”

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

stale-while-revalidate — show me.

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

How do you invalidate a CDN cache?

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.

What headers does the browser send on conditional requests?

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

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

Further reading

Interview angle 6

  • “What Cache-Control do you set for what?” - hashed static assets get public, max-age=31536000, immutable, because the filename changes when the content does. HTML and API responses get no-cache with an ETag so revalidation is cheap. Per-user responses get private. Genuinely sensitive responses get no-store.
  • “What is the difference between no-cache and no-store?” - no-cache means “cache it, but revalidate before serving it”, and no-store means “do not write it down at all”. Confusing them is the canonical caching bug: people write no-cache meaning no-store and are surprised the response is on disk.
  • “How does revalidation actually work?” - the cache sends If-None-Match with the stored ETag, and the server replies 304 Not Modified with headers and no body when it still matches. You pay a round trip and no bytes, which is why no-cache plus ETag is cheap rather than wasteful.
  • “What does Vary do, and how do people get it wrong?” - it adds a request header to the cache key so the right variant is served - Accept-Encoding, Accept-Language, and Origin for CORS. Vary: Cookie or Vary: * effectively disables caching, because almost every request then has a unique key.
  • “What is the risk of forgetting private?” - a shared cache or CDN storing one user’s personalised response and serving it to another. It is a data leak produced by a missing directive, and it does not show up in testing because a single user never sees it.
  • “How many caches are actually in play?” - browser memory, browser disk, the service worker Cache API, the CDN, and your application-level query cache. Cache-Control governs the HTTP layers only; the other two obey their own rules, which is why “why is this stale” usually means finding which layer is answering.