How Many Caching Layers Should a Frontend Have? A Decision Framework for the Cache Stack
Frontend performance work eventually collides with an uncomfortable truth: there is no single "cache" — there is a stack of caches, each with different semantics, invalidation rules, and failure modes, and the architectural question isn't "should we cache?" but "which layers do we actually need, and who owns invalidation at each?" The layers, from closest-to-the-user outward, are: the browser HTTP cache (governed by Cache-Control/ETag, free but coarse), the Service Worker cache (programmable, offline-capable, but you own the invalidation logic), the in-memory client cache (React Query / SWR / Apollo — the data layer's dedupe + staleness window), the CDN edge cache (shared across users, huge leverage, but hardest to invalidate), and the server/application cache (Redis, in-memory — closest to the source of truth). The foundational principle is that every cache layer is a copy of data that can go stale, and each layer you add multiplies the invalidation problem — Phil Karlton's "there are only two hard things in computer science: cache invalidation and naming things" is not a joke, it's the operative constraint. So the decision framework is subtractive, not additive: start with the fewest layers, add a layer only when you can name the specific problem it solves and you have a concrete invalidation story for it. The most important distinction is who the cache is shared by, because that determines invalidation difficulty: a per-user, per-tab in-memory cache (React Query) is trivial to invalidate (it's yours, in your process, gone on reload); a shared CDN edge cache is the hardest (it's shared across all users, and you can't reach into every edge node — you rely on TTLs, cache keys, and purge APIs that take time to propagate). The second key distinction is static vs dynamic content: immutable, content-hashed static assets (app.a1b2c3.js) should be cached forever (Cache-Control: immutable, max-age=31536000) because the hash is the invalidation (a new build = a new URL); dynamic, user-specific, or frequently-changing data should be cached briefly or not at the CDN at all (it's not shareable across users, and staleness is user-visible). The classic decision heuristic: cache immutable assets aggressively and far from the user (CDN, forever); cache dynamic data conservatively and close to the user (in-memory, short TTL) where you control invalidation. The invalidation strategies themselves form a sub-decision: TTL (time-based — simple, but serves stale data within the window), event-based purge (invalidate on write — precise, but requires wiring writes to purges and handles propagation delay), and cache keys / content hashing (change the key when content changes — the cleanest, because there's nothing to invalidate, the new content simply has a new address). The subtle trap is adding layers reflexively (a Service Worker cache "for performance" that now serves stale content and requires its own invalidation logic you didn't budget for) — every layer is a liability as well as an asset. The mental model: caching is a stack of layers (browser HTTP → Service Worker → in-memory client → CDN edge → server), and each added layer multiplies the invalidation problem (the genuinely hard part), so decide subtractively — add a layer only when you can name its specific benefit and its invalidation story; cache immutable content-hashed assets aggressively far from the user (CDN, forever — the hash is the invalidation), cache dynamic/user-specific data conservatively close to the user (in-memory, short TTL, where invalidation is trivial because it's yours), and prefer content-hash cache keys (nothing to invalidate) > event-based purge (precise) > TTL (simple but stale-within-window).
The Cache Stack at a Glance
| Layer | Governed by | Shared by | Invalidation difficulty |
|---|---|---|---|
| browser HTTP cache | Cache-Control/ETag | one user | easy (per-user, coarse) |
| Service Worker cache | your JS (Cache API) | one user | you own it (medium) |
| in-memory client (React Query/SWR) | the data lib | one tab/user | trivial (yours, gone on reload) |
| CDN edge | cache keys + purge API | all users | hardest (shared, propagation delay) |
| server/app (Redis) | your code | all users | medium (close to source) |
| Content type | Strategy |
|---|---|
immutable, content-hashed (app.a1b2c3.js) | cache forever (immutable, max-age=1yr), far from user (CDN) |
| dynamic / user-specific | short TTL or no CDN cache; close to user (in-memory) |
GIF via GIPHY
| Invalidation strategy | Trade |
|---|---|
| content hash / cache key | cleanest — nothing to invalidate (new content = new URL) |
| event-based purge | precise, but wire writes→purges + propagation delay |
| TTL | simple, but serves stale within the window |
The Subtractive Framework: Every Layer Multiplies Invalidation
The instinct is to add caches for performance; the discipline is to recognize each layer as a copy that can go stale, so the invalidation cost compounds:
Each cache layer = another COPY of the data that can diverge from the source of truth.
N layers → N places to invalidate → N chances to serve stale content.
"Two hard things: cache invalidation and naming things." — invalidation is the REAL cost.
DECISION RULE (subtractive, not additive):
start with the FEWEST layers. Add a layer ONLY when you can state:
1. the SPECIFIC problem it solves (name the slow path it fixes), AND
2. its INVALIDATION story (how/when this copy gets refreshed or purged).
No invalidation story → no cache layer. A cache you can't invalidate is a bug generator.
The key variable is who shares the cache, because sharing is what makes invalidation hard:
PER-USER / PER-TAB (in-memory: React Query, SWR): trivial to invalidate — it's IN YOUR PROCESS,
you call queryClient.invalidateQueries(), and it's gone on reload anyway. Low risk.
SHARED ACROSS ALL USERS (CDN edge): HARDEST — you can't reach into every edge node instantly;
you depend on TTLs, cache KEYS, and purge APIs that take SECONDS-TO-MINUTES to propagate.
A wrong CDN cache entry is served to everyone until it expires or you purge it.
So the safe place to cache aggressively is close to the user where the cache is yours (in-memory, short TTL, trivial invalidation); the high-leverage-but-high-risk place is the shared CDN, reserved for content whose invalidation is free — immutable assets.
Static vs Dynamic, and the Invalidation Sub-Decision
The content-type split determines where and how long to cache:
IMMUTABLE, CONTENT-HASHED assets (app.a1b2c3.js, chunk.9f8e7d.css):
the hash IS the invalidation — a new build produces a NEW URL, so the old cached copy is
simply never requested again. Cache FOREVER, far from the user:
Cache-Control: public, max-age=31536000, immutable
(index.html, which references the hashed assets, is the ONE thing cached briefly/revalidated —
it's the pointer that changes.)
DYNAMIC / USER-SPECIFIC data (a user's cart, a personalized feed):
NOT shareable across users → do NOT cache at the shared CDN (you'd leak one user's data or
serve staleness to all). Cache CLOSE to the user (in-memory, short TTL) where you control it.
The invalidation strategies, best to worst by invalidation cost:
CONTENT HASH / CACHE KEY (best): change the key when content changes. There is NOTHING to
invalidate — the new content has a new address; the old one just ages out unused. This is
why content-hashed bundles can be cached forever. Push problems into the KEY.
EVENT-BASED PURGE (precise): on a write, purge the affected cache entries (CDN purge API,
queryClient.invalidateQueries, cache tags). Accurate, but you must WIRE every write to its
purges (easy to miss one) and tolerate propagation delay on shared caches.
TTL (simplest): entries expire after N seconds. Zero wiring, but serves STALE data for up to N
seconds — acceptable for tolerant content (a product list), unacceptable for a bank balance.
The reflexive-layering trap: adding a Service Worker cache "for performance" that now serves stale HTML/JS after a deploy, requiring its own cache-versioning and update logic (skipWaiting, cache-busting) you didn't budget for. Every layer added is invalidation logic acquired. Add it only when the benefit is named and the invalidation story is written.
In Practice: What Goes Wrong
Scenario 1: The Service Worker That Served Last Week's App
A team added a Service Worker cache "for offline/perf," using cache-first for everything; after a deploy, users kept getting the old app (stale JS/HTML) until they hard-refreshed, and bug reports poured in. Root cause: a cache layer added without an invalidation story — cache-first serves the stale copy indefinitely, and the SW had no versioning/update logic. Fix: either remove the layer (if the perf benefit wasn't real) or add proper invalidation (network-first for HTML, content-hashed assets with cache-first, SW cache versioning + skipWaiting). Every cache layer needs an invalidation strategy before it ships; a cache-first Service Worker without versioning is a stale-content generator.
Scenario 2: The CDN That Cached a User's Private Data
GIF via GIPHY
A personalized dashboard endpoint was cached at the CDN with a generic key, and one user's data was served to others (a privacy incident) until the TTL expired. Root cause: dynamic, user-specific data cached at the shared CDN layer — the CDN is shared across users, so caching per-user data there leaks it. Fix: don't cache user-specific responses at the shared CDN (mark them Cache-Control: private, no-store at the edge); cache them close to the user (in-memory, per-session). Shared caches are only for shareable content; user-specific data belongs in per-user caches where the cache boundary matches the data's audience.
Scenario 3: The Aggressive Bundle Cache That Wouldn't Update
A team set max-age=31536000 on bundle.js (no content hash in the filename), so after deploys users ran old JS against a new API for up to a year. Root cause: aggressive caching applied to a non-content-hashed URL — the same URL now had to serve new content, but the browser held the old copy per the long TTL, and there was nothing to change the key. Fix: content-hash the filename (bundle.a1b2c3.js) so a new build produces a new URL (the hash is the invalidation), and only then cache forever; keep the HTML pointer short-lived/revalidated. Aggressive caching is safe only on content-hashed URLs; on stable URLs it's a footgun.
Tradeoffs and Engineering Decisions
- Decide subtractively — each layer multiplies invalidation. Every cache is a copy that can go stale; N layers means N places to invalidate. Start with the fewest and add a layer only when you can name its specific benefit and write its invalidation story. A cache you can't invalidate is a bug generator, not a performance win.
- Match the cache's audience to the data's audience. Per-user data → per-user cache (in-memory, trivial to invalidate, gone on reload). Shared/immutable content → shared cache (CDN). Caching user-specific data at a shared CDN leaks it or serves staleness to everyone; the cache boundary must match the data's audience.
- Cache immutable content-hashed assets forever, far from the user; dynamic data briefly, close to the user. The content hash is the invalidation (new build → new URL → nothing to purge), making CDN-forever safe. Dynamic data has no such free invalidation, so keep it close (short TTL, your process) where you control refresh.
- Prefer content-hash keys > event purge > TTL. Content hashing eliminates invalidation (push the problem into the key). Event purge is precise but needs every write wired to its purges and tolerates propagation delay. TTL is simplest but serves stale within the window — fine for tolerant content, not for correctness-critical data. Choose per the content's staleness tolerance.
GIF via GIPHY
Key Takeaways
- Caching is a stack (browser HTTP → Service Worker → in-memory client → CDN edge → server), and each added layer multiplies the invalidation problem — the genuinely hard part ("cache invalidation and naming things"). Decide subtractively: add a layer only when you can name its specific benefit and its invalidation story.
- Who shares the cache determines invalidation difficulty: per-user in-memory caches (React Query/SWR) are trivial to invalidate (yours, gone on reload); the shared CDN is the hardest (all users, propagation delay). Cache aggressively where the cache is yours, conservatively where it's shared.
- Cache immutable, content-hashed assets forever, far from the user (
immutable, max-age=1yrat the CDN — the hash is the invalidation); cache dynamic/user-specific data briefly, close to the user (short TTL, in-memory) — and never cache user-specific data at the shared CDN (it leaks or staleness-broadcasts). - Invalidation strategies, best to worst by cost: content-hash cache keys (nothing to invalidate — new content, new URL) > event-based purge (precise; wire writes→purges, tolerate propagation) > TTL (simple; stale within the window).
- The trap is reflexive layering — a Service Worker or bundle cache added "for performance" that now serves stale content and demands its own invalidation logic you didn't budget for. Every layer is a liability as well as an asset; content-hash your URLs before caching them aggressively, and match every cache's audience to its data's audience.
GIF via GIPHYWhat did you think?