Frontend / Browser internals / 08_caching_headers.md

HTTP Caching Headers — Cache-Control, ETag, Vary

Updated 7 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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

In depth

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=.... Pair with Vary: Authorization if you want the CDN to cache differently per auth header (rarely useful).

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 / 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 6

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