Caching for front-end performance: the layers, and what each one buys you
Caching removes work instead of speeding it up, but a front end has half a dozen independent caches: memoization, the request cache (TanStack Query), normalized stores, a persisted cache, the service worker, hashed assets, and prefetching. What each removes, which metric it moves, and how to choose.
Caching is the highest-leverage performance work because it removes work entirely instead of making it faster. But "add caching" is not a plan. A front end has half a dozen independent caches, each removing a different kind of work and moving a different metric. This is the menu, what each layer costs, and how to pick. The HTTP and CDN transport layers have their own post; this one is everything above the network.
The layers at a glance#
| Layer | Removes | Moves | Invalidation |
|---|---|---|---|
| Memoization (useMemo, memo, selectors) | Recomputation and re-renders | INP, frame rate | Easy (deps array) |
| Request cache (TanStack Query, SWR) | Duplicate and repeat network calls | Perceived latency, request count | Medium (keys, staleTime) |
| Normalized cache (Apollo, RTK Query) | Over-fetching, inconsistent copies | Payload size, consistency | Hard |
| Persisted client cache (localStorage, IndexedDB) | Cold-start fetches on repeat visits | Repeat-visit load | Medium (maxAge, buster) |
| Service worker cache | Network round trips; enables offline | Repeat-visit LCP, offline | Hard (SW lifecycle) |
| Hashed asset / module cache | Re-downloading unchanged code | Repeat-visit load | Trivial (content hash) |
| Prefetch / cache warming | The wait, by moving it earlier | Perceived latency on the next action | N/A (speculative) |
1. Memoization: caching computation and renders#
The cache is "this input produced this output, do not compute it again". In React that shows up as:
useMemofor expensive derived values,useCallbackfor stable references,React.memofor components whose parent re-renders often with stable props.- Selector memoization:
reselect, or selectors passed touseSyncExternalStore/ a store'suseSelector, so a component re-renders only when its slice changes. - Module-level memo for pure expensive functions: a
Mapor an LRU keyed by the arguments. Good for formatters, parsers, regex compilation, layout math. React.cache()in a Server Component to dedupe a call within one render pass.
// cache an expensive pure function at module scope
const cache = new Map<string, LayoutResult>()
export function computeLayout(input: LayoutInput): LayoutResult {
const key = JSON.stringify(input)
const hit = cache.get(key)
if (hit) return hit
const result = expensiveLayout(input) // the work we do not want to repeat
cache.set(key, result)
if (cache.size > 200) cache.delete(cache.keys().next().value) // crude bound
return result
}useMemo and React.memo are not free. Every one adds a dependency check and holds a reference. Measure with the Profiler first (performance post). A component that renders in under a millisecond does not need memoising, and the bookkeeping can cost more than the render.2. The request cache: TanStack Query and SWR#
The core value is three things at once: it deduplicates in-flight requests, serves cached data instantly, and revalidates in the background. Five components calling useQuery with the same key make one request and all render from one cache entry.
const { data, isPending } = useQuery({
queryKey: ["invoice", id],
queryFn: () => fetchInvoice(id),
staleTime: 60_000, // trust it for a minute: no refetch on remount within 60s
gcTime: 15 * 60_000, // keep it 15 min after the last component using it unmounts
})staleTimeis how long data is trusted without a refetch.gcTimeis how long an unused entry stays in memory. They are unrelated.- Query keys are the index. After a mutation, invalidate the smallest key that covers the change, never the whole cache.
placeholderData: keepPreviousDatakeeps the current page visible while the next one loads, so filters and pagination do not flash a spinner.- Prefetch on link hover or in a route loader to warm the entry before navigation.
With SSR
The server already fetched the data to render the HTML. To stop the client refetching it on mount: create a fresh QueryClient per request, prefetchQuery what the page needs,dehydrate it into the payload, wrap the client tree in HydrationBoundary, and give the client QueryClient a non-zero staleTime so the hydrated data counts as fresh.
QueryClient created in module scope on the server is shared across requests, so one user can see another's data. Create it per request on the server, and use a singleton only in the browser.What it moves: perceived latency (instant from cache), request count, and INP through fewer loading transitions. It does not help first-visit LCP unless paired with SSR.
3. Document cache vs normalized cache#
| Document cache (Query, SWR) | Normalized cache (Apollo, RTK Query) | |
|---|---|---|
| Stores | One response blob per key | One copy per entity id, referenced everywhere |
| Same entity in two views | Two copies that can disagree | One copy, always consistent |
| After a mutation | Invalidate or write the affected keys | Update the entity, every view updates |
| Cost | Almost none | A normalization layer, cache-policy config, more concepts |
Pick normalized when the same entities appear across many views and must stay consistent: a CRM, an admin console, a dashboard with cross-linked data. Pick a document cache, which is most apps, when views are mostly independent.
4. Persisting the client cache#
Write the request cache to disk and restore it on boot, so the second visit shows real data before any network call.
persistQueryClientwith a sync persister forlocalStorage(small caches) or an async one backed by IndexedDB (larger).maxAgediscards a persisted cache that is too old. Abusterstring you bump on a schema change invalidates everything at once.- Exclude user-specific and sensitive queries from what gets written. Storage rules and trade-offs are in the storage post.
- Always pair it with a background revalidate, so the restored data is corrected within a second.
What it moves: repeat-visit load and time-to-interactive-with-real-data.
5. Service worker cache#
Workbox gives you named strategies, one per kind of resource:
| Strategy | For |
|---|---|
| CacheFirst | Hashed assets, fonts: never changes, serve from cache, skip the network |
| StaleWhileRevalidate | Semi-static JSON, avatars: serve cached, refresh in the background |
| NetworkFirst | HTML, API you want live: try network, fall back to cache when offline |
| NetworkOnly | Auth, payments, anything that must never be stale |
Precache the build output for an instant repeat load and an offline shell.
6. Asset and module cache#
Content-hashed filenames plus Cache-Control: immutable mean unchanged code is never downloaded twice. This is the cheapest and most reliable cache you have, and it needs no invalidation logic because the filename is the version.
- Route-based code splitting so the cache granularity matches what actually changes between deploys.
<link rel="modulepreload">to warm the module cache for the JS of the route the user is about to open.- Details of the HTTP side are in the CDN post.
7. Prefetching: caching the future#
Warm a cache before the user needs it, so the wait happens in the background instead of after a click.
- Route data on link hover or when a link scrolls into view:
prefetchQuery, or the framework's route loader prefetch. <link rel="prefetch">for the next document at low priority,rel="preload"for a critical resource on the current page,modulepreloadfor the next route's JS.- The Speculation Rules API can prerender the next page entirely, for near-zero navigation.
The trade-off is wasted bandwidth when the guess is wrong. Prefetch on strong intent (hover, viewport), and prerender sparingly. Back off on a metered or slow connection (navigator.connection.saveData).
Which layer for which problem#
| Symptom | Reach for |
|---|---|
| It refetches the same data on every screen | Request cache: raise staleTime, one shared key |
| Typing or scrolling is janky | Memoization, fewer re-renders, useDeferredValue |
| The second visit is as slow as the first | Persisted cache + immutable assets + SW precache |
| The list flashes a spinner on every filter change | keepPreviousData |
| Navigation feels slow | Prefetch on hover / route loaders |
| Two parts of the UI show different values for one thing | Normalized cache |
| First paint is slow for everyone | SSR/SSG + CDN edge (other post), not a client cache |
Invalidation, per layer#
- Memoization: the dependency array. Wrong deps means stale values or no caching at all.
- Request cache:
staleTimefor time-based,invalidateQuerieson a key branch after a mutation. - Persisted cache:
maxAgeand abusteryou bump on schema changes. - Service worker: a versioned precache and
skipWaiting, plus a prompt to reload. - Hashed assets: nothing to do. A new build produces new filenames.
The caches that are easy to invalidate are the ones keyed by content or by an explicit version. The ones keyed by "a URL that stayed the same while its contents changed" are the ones that hurt.
Measuring the payoff#
Each cache moves a specific metric: memoization moves INP, the request cache moves perceived latency and request count, the persisted and SW caches move repeat-visit load. Do not guess. Check the field p75, a DevTools trace, and the bundle analyzer as covered in the performance post. A cache that does not move a metric you track is just risk.
Anti-patterns#
staleTime: Infinityon data that changes.- Memoising everything, so the dependency checks cost more than the work saved.
- One coarse query key, so nothing can be invalidated precisely.
- A persisted cache with no
maxAge, serving week-old data on a return visit. - A service worker added to a site that has no reason to work offline.
- Caching per-user data at a shared layer: a CDN edge, or a persisted cache on a shared machine.
- Prefetching aggressively on a metered connection.