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:
- Server sends response with
ETag: "abc123"(a content fingerprint). - Browser caches the response.
- On re-request, browser sends
If-None-Match: "abc123". - Server compares to current ETag:
- Unchanged →
304 Not Modifiedwith no body (tiny). - Changed →
200 OKwith new body + new ETag.
- Unchanged →
# 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:
- Code calls
fetch("/api/x"). - If SW is active, SW’s
fetchhandler runs first (can serve from Cache API). - Otherwise, browser HTTP cache (per
Cache-Control). - 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:
- Versioned URLs — hash in the filename (
/app.a1b2c3.js). New version = new URL = no invalidation needed. The old URL stays cacheable; nobody requests it. - 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
ETagwas present:If-None-Match: "<etag>". - If
Last-Modifiedwas 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-cacheis misnamed — it means “revalidate before serving,” not “don’t cache.”no-storeis “don’t cache.”- Missing
privateon per-user data can leak to other users via CDN. Real data-leak bug; happens. Cache-Control: max-age=0vsno-cache— practically equivalent (forces revalidation), butno-cacheis 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: publicoverridingprivate— if upstream and origin disagree, the order in the header matters. Don’t mix.Expiresheader (legacy) — pre-Cache-Control. If both present,Cache-Controlwins. 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-cacheis ‘revalidate before serving,’ not ‘don’t cache.’no-storeis the actual ‘don’t cache.’ Confusing them is the canonical caching bug.” - “
ETag+If-None-Match→304 Not Modified(headers only). Cheap revalidation for content that may or may not have changed.” - “
Vary: Accept-Encoding, Accept-Languageso caches serve the right variant. Don’tVary: *orVary: Cookie(kills caching).” - “
privateon per-user responses or you risk leaking via CDN.” - “Layered caches: browser memory → disk → SW Cache API → CDN → origin.
Cache-Controlgoverns HTTP layers; SW Cache API is separate; app caches (TanStack Query) wrap the whole thing.”
Cross-references
- Backend HTTP semantics (full spec): ../../backend/12_protocols/http/03_http_semantics_and_caching.md
- Service Worker caching: 05_service_workers_pwa.md
- TanStack Query (app cache layer): ../11_apis_data_fetching/02_tanstack_query.md
- Resource hints (preload/prefetch interact): ../15_performance/08_resource_hints.md
Further reading
- MDN — Cache-Control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- MDN — ETag: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
- MDN — Vary: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
- Jake Archibald — “Caching best practices & max-age gotchas”: https://jakearchibald.com/2016/caching-best-practices/
- RFC 9111 — HTTP Caching: https://datatracker.ietf.org/doc/html/rfc9111