Back to Performance
Performance
medium
mid

What are the different layers of caching you can use on a website?

Multiple layers: HTTP cache (Cache-Control, ETag) for browser + intermediate caches; CDN (long max-age for hashed assets, short with revalidate for HTML); service worker for offline + custom strategies; in-memory app cache (React Query, Redux); localStorage/IndexedDB for persistent client data. Strategy: 'immutable + far-future' on versioned URLs (hashes), 'stale-while-revalidate' for content, 'no-store' for personalized/sensitive. Cache keys must include anything that changes the response (auth, locale, Vary headers).

10 min read·~5 min to think through

Web caching is a stack — each layer catches what the layer above misses, and together they cut origin load by 90%+ for typical sites.

The layers (closest to user → furthest)

1. Browser HTTP cache

Built into every browser. Driven by response headers:

ts
Cache-Control: public, max-age=31536000, immutable
ETag: "abc123"
Last-Modified: Wed, 17 May 2026 12:00:00 GMT
  • max-age — how long the response is fresh (no revalidation).
  • immutable — promise that the resource won't change before max-age expires; browser skips even revalidation on user reload.
  • ETag / Last-Modified — opaque/timestamp markers for conditional revalidation. Browser sends If-None-Match / If-Modified-Since; server returns 304 (empty body) if unchanged.
  • s-maxage — like max-age but only for shared caches (CDNs).
  • stale-while-revalidate=N — serve stale up to N seconds while fetching fresh in background.
  • no-cache — cache but always revalidate.
  • no-store — never cache (auth-only, personalized).
  • private — only the browser, not shared caches.

Standard pattern:

  • Hashed assets (main.abc123.js, logo.def456.png): Cache-Control: public, max-age=31536000, immutable. Cached forever.
  • HTML: Cache-Control: public, max-age=0, must-revalidate or short max-age + ETag. Always fresh on next nav.

2. CDN / edge cache

CloudFront, Cloudflare, Fastly, Vercel Edge. Shared across all users; cache HTML and assets close to user.

  • Same headers (Cache-Control: s-maxage=…).
  • Vary on Accept-Encoding, Accept-Language if responses differ.
  • Cache key normally URL; can extend with query whitelist, cookies.
  • Purge by URL or tag (Cloudflare cache tags, Fastly surrogate keys).

3. Service Worker (custom strategies)

When the browser cache isn't expressive enough:

  • Cache-first for static assets.
  • Network-first with cache fallback for HTML (offline support).
  • Stale-while-revalidate for content that's fine being slightly old.
  • Network-only for auth, mutations.
  • Pre-cache at install for the app shell.

Workbox makes this manageable.

4. In-memory app cache (React Query / RTKQ / SWR / Redux)

  • Caches API responses by query key.
  • Deduplicates concurrent requests.
  • TTL and tag-based invalidation.
  • Survives component unmount but not page reload.

This is where most "API caching" lives in modern apps.

5. localStorage / sessionStorage / IndexedDB

For client-side persistent state:

  • localStorage — 5–10MB, sync API, sticky across sessions.
  • sessionStorage — same but per-tab.
  • IndexedDB — gigabytes, async, structured queries. Right for big offline datasets, Dexie or idb wrapper for sanity.
  • Cache API (within SW context) — Request/Response pairs, perfect for SW caching.

Watch out: never store PII or auth tokens in localStorage (XSS-readable). HttpOnly cookies for tokens.

Cache keys — the hard part

If two users get different responses for the same URL, the cache must distinguish:

  • Auth: Authorization: Bearer … responses are per-user; Cache-Control: private (browser only) or no-store. Don't put behind shared caches.
  • Locale: Vary: Accept-Language so the cache stores one per language.
  • Cookies: by default CDN caches strip cookies and ignore them; if your response varies by cookie, configure the cache key explicitly.
  • Device: mobile vs desktop variants — Vary: User-Agent is too broad (every UA string becomes a cache entry); use Client Hints or render mobile-first responsive.

Wrong cache key = users seeing each other's data. The most embarrassing cache bug.

Strategy matrix

ResourceCache layerHeader
Versioned JS/CSSBrowser + CDN foreverpublic, max-age=31536000, immutable
Images (versioned)Browser + CDN foreverpublic, max-age=31536000, immutable
HTML (public)CDN short + browser revalidatepublic, max-age=0, s-maxage=60, stale-while-revalidate=300
HTML (per-user)No cachingprivate, no-store
API (public, slow-changing)App cache + browser shortmax-age=60, stale-while-revalidate=3600
API (per-user)App cache only (React Query)private, no-store
Auth endpointsNeverno-store

Common pitfalls

  • Long-cached HTML referencing a chunk that got rebuilt with a new hash → 404 on next deploy.
  • no-cache confused with no-store (no-cache does cache, just revalidates).
  • Mixing Cache-Control and ExpiresCache-Control wins; remove Expires to avoid confusion.
  • Forgetting Vary when serving compressed (gzip/brotli) responses (most CDNs handle this; double-check).
  • Setting long cache on /api/me → user sees previous user's data.
  • Service worker caching old shell + serving new chunks → fatal version mismatch.

Invalidation

The two hard problems in computer science: naming things, cache invalidation, and off-by-one errors.

  • Versioned URLs (hashes): never invalidate; serve forever, change the URL on update. Use for assets.
  • Soft purge / revalidate: CDN tag-purge an entire content type (e.g., all product pages after a price change).
  • TTL: accept some staleness; pick TTL to match how often the content changes.
  • stale-while-revalidate: serve stale instantly, fetch fresh in background — best of both for non-personal content.

Mental model

Cache aggressively at the layer closest to the user. Use immutable URLs to make cache invalidation a non-problem. Personalize at the closest layer to the user too (browser, edge with auth-aware Workers) so the rest of the stack can cache shared content.

Follow-up questions

  • What's the difference between no-cache and no-store?
  • How do you cache personalized HTML at the edge?
  • What is stale-while-revalidate and when does it apply?
  • How do you handle cache invalidation across thousands of CDN nodes?

Common mistakes

  • Caching personalized content shared at the CDN — data leak between users.
  • no-cache and no-store mix-up — no-cache still caches.
  • Forgetting Vary on responses that legitimately differ.
  • Long-cached HTML + frequently updated assets without graceful version handling.
  • Storing tokens in localStorage — XSS leaks them.
  • Service worker caching old app shell + new chunks → broken app.

Performance considerations

  • Done right, cache hits return in <50ms (browser), <100ms (CDN). A 90% cache hit rate cuts origin load 10x and lets the origin be small/cheap. Cache misses are unavoidable but should be rare — measure hit ratio per resource type.

Edge cases

  • Range requests for video/audio interact with cache differently — most CDNs handle.
  • HTTP/2 push (deprecated) was an alternative to preload + cache; Early Hints (103) is the modern equivalent.
  • Cookie-keyed caching scales poorly (high cardinality) — extract relevant signal into a header instead.
  • Brotli vs gzip — same URL, different encoding — Vary: Accept-Encoding handles it.
  • Cache freshness on back/forward navigation differs from forward navigation (bfcache).

Real-world examples

  • Major sites use Cloudflare/Fastly + tag-based invalidation for HTML.
  • Next.js + Vercel: ISR + edge cache + tag invalidation built-in.
  • Hashed-filename builds (webpack/vite) are the standard for assets.
  • GitHub's API uses ETag heavily — cached requests don't count against rate limit.

Senior engineer discussion

Seniors design the cache strategy alongside the deploy story. They distinguish personalized from shared content, immutable from mutable, and pick the right layer for each. They also know cache invalidation is the hard problem and prefer architectures (hashed URLs, tag-based purge) that make it solvable.

Related questions