Portfolio/Writing/Caching for front-end performance: the layers, and what each one buys you

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#

LayerRemovesMovesInvalidation
Memoization (useMemo, memo, selectors)Recomputation and re-rendersINP, frame rateEasy (deps array)
Request cache (TanStack Query, SWR)Duplicate and repeat network callsPerceived latency, request countMedium (keys, staleTime)
Normalized cache (Apollo, RTK Query)Over-fetching, inconsistent copiesPayload size, consistencyHard
Persisted client cache (localStorage, IndexedDB)Cold-start fetches on repeat visitsRepeat-visit loadMedium (maxAge, buster)
Service worker cacheNetwork round trips; enables offlineRepeat-visit LCP, offlineHard (SW lifecycle)
Hashed asset / module cacheRe-downloading unchanged codeRepeat-visit loadTrivial (content hash)
Prefetch / cache warmingThe wait, by moving it earlierPerceived latency on the next actionN/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:

  • useMemo for expensive derived values, useCallback for stable references, React.memo for components whose parent re-renders often with stable props.
  • Selector memoization: reselect, or selectors passed to useSyncExternalStore / a store's useSelector, so a component re-renders only when its slice changes.
  • Module-level memo for pure expensive functions: a Map or 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.
ts
// 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
}
Do not memo blind
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.

ts
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
})
  • staleTime is how long data is trusted without a refetch. gcTime is 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: keepPreviousData keeps 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.

The SSR bug that leaks data between users
A 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)
StoresOne response blob per keyOne copy per entity id, referenced everywhere
Same entity in two viewsTwo copies that can disagreeOne copy, always consistent
After a mutationInvalidate or write the affected keysUpdate the entity, every view updates
CostAlmost noneA 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.

  • persistQueryClient with a sync persister for localStorage (small caches) or an async one backed by IndexedDB (larger).
  • maxAge discards a persisted cache that is too old. A buster string 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:

StrategyFor
CacheFirstHashed assets, fonts: never changes, serve from cache, skip the network
StaleWhileRevalidateSemi-static JSON, avatars: serve cached, refresh in the background
NetworkFirstHTML, API you want live: try network, fall back to cache when offline
NetworkOnlyAuth, payments, anything that must never be stale

Precache the build output for an instant repeat load and an offline shell.

Only add one if you need offline
A service worker is a second deployment with its own lifecycle. The most common PWA bug report is "I deployed but users still see the old version". If you do not need offline or near-instant repeat loads, the HTTP and hashed-asset caches already give you most of the speed with none of the update ceremony.

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, modulepreload for 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#

SymptomReach for
It refetches the same data on every screenRequest cache: raise staleTime, one shared key
Typing or scrolling is jankyMemoization, fewer re-renders, useDeferredValue
The second visit is as slow as the firstPersisted cache + immutable assets + SW precache
The list flashes a spinner on every filter changekeepPreviousData
Navigation feels slowPrefetch on hover / route loaders
Two parts of the UI show different values for one thingNormalized cache
First paint is slow for everyoneSSR/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: staleTime for time-based, invalidateQueries on a key branch after a mutation.
  • Persisted cache: maxAge and a buster you 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: Infinity on 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.
Summary
A front end has several caches, and each removes a different kind of work. Memoization caches computation and renders (moves INP). The request cache dedupes and revalidates network calls (moves perceived latency); a normalized cache adds cross-view consistency at a real cost. A persisted cache and a service worker make repeat visits fast and can add offline. Hashed assets are the free, unbeatable one. Prefetching moves the wait earlier. Pick the layer that targets the metric you are actually tracking, keep every cache keyed by content or an explicit version so invalidation stays cheap, and measure before and after.

Next up
Curl it before you build the UI: API design from the front end’s side of the table

If the front end is "just CSS", the API should be good enough to prove a feature works with no front end at all: a curl, an assertion, a green check in CI, before a single component exists. Shape responses around screens, keep business logic where a request can test it, and stop making the browser your second backend.

Read next →