CachingGENERALFRAMEWORK-SPECIFICBROWSER-SPECIFIC

The Client Cache Model

A key, an entry, a freshness state and a revalidation rule — the cache your application owns, which is not the browser's HTTP cache and does not obey its headers.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

When my application already has this data, what decides whether it asks the server for it again?

The user intent

Someone navigates back to a list they were looking at ten seconds ago. They expect it to be there immediately, and they expect it to be right. Those two expectations are in tension and the cache is where you resolve it.

The obvious build

Fetch inside the component, put the result in local state, and add a loaded flag so it does not fetch twice. If it ever needs refreshing, add a refresh button.

Why it breaks

Two components on the same screen need the same list, so two requests go out, two copies land in two pieces of component state, and two spinners appear. The copies then drift, because only one of them refetches.

How it breaks in a real browser
  • Two components on the same screen need the same list, so two requests go out, two copies land in two pieces of component state, and two spinners appear. The copies then drift, because only one of them refetches.
  • The loaded flag lives in component state, so unmounting throws it away. Back-navigation refetches from scratch and the list flashes empty on a screen the user has already seen (Client-Side Routing).
  • The flag has no notion of age. Data fetched four seconds ago and data fetched forty minutes ago are treated identically, which means the policy is "never refetch" — and nobody wrote that down as a decision.
  • A mutation elsewhere in the app changes one of those rows. The component holding the copy has no way to hear about it, so the screen is wrong until the user reloads (State Synchronization).
  • Adding Cache-Control headers to the endpoint does not fix any of this. Those headers govern a different cache, at a different layer, with different rules (Browser HTTP Caching).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A client cache is a map from a key you choose to an entry: the last successful payload, the last error, a timestamp of when the payload was written, a status, the in-flight promise if a request is running, and the count of mounted views currently rendering it.
  • Freshness is a policy over that timestamp. An entry is fresh while its age is under a staleness window you configure, and stale after. Stale does not mean unusable or deleted — it means "render this, and consider asking again".
  • There are two independent clocks. One decides when an entry stops being believable without a check (staleness). The other decides when an entry nobody is rendering may be dropped from memory (retention, garbage collection). Conflating them is why caches either refetch constantly or grow without bound.
  • Rendering a key is a subscription. Views that read a key are registered against the entry, so a write notifies exactly the views that care, rather than re-rendering the tree (Who Owns This State?).
  • Concurrent reads collapse. Two views asking for the same key in the same tick join one in-flight promise rather than starting two requests (Five Components, One Request, Single-Flight Coalescing).
  • All of this lives in your JavaScript heap, inside one document. Reload the tab and it is gone; open a second tab and it has its own, unrelated copy — unless you deliberately persist or synchronise it (Persistent Client State).
  • The browser HTTP cache is a different layer entirely: keyed by URL, method and Vary, governed by response headers the server sent, shared across tabs, persisted to disk, and not readable or writable from your code (Browser HTTP Caching).

What this makes the browser do

And which of it is avoidable.

  • The cache is an object graph in the heap. Every entry retains its payload and, transitively, everything derived from it that you kept a reference to — which is how a cache and a leak become hard to tell apart (Memory Leaks).
  • A cache hit that renders is still a full render for the region: reconciliation, DOM mutation where the output differs, style, layout and paint (The Cost of a Change).
  • A cache miss is a fetch: connection reuse or setup, an HTTP cache lookup the browser does on your behalf, the response, and a synchronous JSON.parse on the main thread proportional to payload size (The Life of a Fetch).
  • A write notifies every subscriber, so one response can schedule several component renders. Structural sharing — reusing the previous object references for the parts of the payload that did not change — is what stops an unchanged refetch from re-rendering the whole list.
  • Persisting the cache adds serialisation cost on write and parse cost on restore, both on the main thread unless you move them (IndexedDB, Web Workers and the DOM Boundary).

Two caches, two rule sets, one word

The most common confusion in this module is not about staleness or invalidation. It is that "cache" refers to two different systems that sit at different layers, are configured by different people, and answer different questions — and a fix applied to the wrong one does nothing at all.

The browser HTTP cache is the server's instrument. The server declares, in response headers, how long a response may be reused and how it may be revalidated, and the browser enforces that for every request made from any page on that site. Your JavaScript cannot read it, cannot enumerate it, and can only influence it through the cache option on a request.

The client cache is yours. You choose what identifies an entry, how long it stays believable, when it is checked and when it is dropped. It knows nothing about ETags or max-age unless you write that code. The two compose: a client cache miss becomes a fetch, which the browser may serve from the HTTP cache, revalidate conditionally, or send to the network.

  • A client cache hit makes no request, so no HTTP cache rule applies to it and no Cache-Control header can affect it.
  • A client cache miss makes a request, and *then* the HTTP cache gets a say — including possibly returning a stored response with no network at all (Browser HTTP Caching).
  • Static assets are the HTTP cache's job, and content hashing is how you get both long lifetimes and instant updates (Content-Hashed Assets).
  • A service worker is a third layer, sitting between the two and able to override both (Intercepting Fetch).
QuestionClient (application) cacheBrowser HTTP cache
What is the key?Whatever you decide — resource, filters, page, identityURL plus method, further split by the response's Vary header
Who configures it?Frontend code, per queryThe server, via response headers
What is stored?Parsed application objects in the JS heapRaw HTTP responses, in memory and on disk
Can your code read it?Yes — it is your objectNo. Only influence it via the fetch cache option
How long does it live?Until the document unloads, unless persistedAcross reloads, tabs and sessions, subject to eviction
ScopeOne document. A second tab has its ownThe whole browser profile, partitioned by top-level site
What does a hit cost?Nothing — no request is madePossibly a conditional round trip to revalidate
What invalidates it?Your invalidation call, or the staleness windowHeader expiry, revalidation, or the browser evicting it
What it is good atSharing one copy of server state across a UI, and updating itNot re-downloading bytes, especially static assets

Key, entry, freshness, revalidation

The whole model is four moving parts and the transitions between them. A key identifies an entry. An entry has an age. The age crossing the staleness window changes the entry's state from fresh to stale. A trigger — a mount, a window focus, an interval, an explicit invalidation — turns a stale entry into a refetch, and the response writes the entry and resets its age.

Notice what is *not* in that loop: nothing deletes data on the way through. A stale entry still renders. That is the property the rest of the module is built on, and it is the difference between a cache and a memo — a memo either has the value or does not, while a cache has an opinion about how much it trusts the value it has.

The life of one cache entry
identifiesage < staleTimeage >= staleTimeno requestrender anywayrevalidateresets agere-render subscribersobservers = 0Query key — what identifies this dataTrigger: mount, focus, interval, invalidateCache entry: data + updatedAt + statusFresh — render, ask nothingRetention clock — drop when unobservedStale — render, and consider askingRefetch, deduplicated per keyWrite + notify subscribersRendered UI
UserLLMAgentToolDataDecisionHumanGuardrail

What an entry actually holds

Written out as a type, the model stops being mysterious. Two fields do most of the work — updatedAt, because freshness is entirely a function of it, and inflight, because that single slot is what turns three simultaneous callers into one request.

The important thing to read here is what is absent. There is no ETag, no Cache-Control, no conditional request, no 304. Those belong to the layer below, and the browser handles them without telling you. If you find yourself wanting them in this object, what you actually want is for the server to send them so that the fetch this cache makes on a miss is cheap (Conditional Requests: ETags, 304 and 412).

A client cache entry, reduced to what matters
1type Entry<T> = {
2 key: string // the identity YOU chose — everything that varies the response
3 data?: T // last successful payload, kept even when a later fetch fails
4 error?: unknown // last failure — alongside data, never instead of it
5 updatedAt: number // when data was written; freshness is a policy over this
6 status: 'pending' | 'success' | 'error'
7 inflight?: Promise<T> // the dedup slot: a second caller joins, it does not start a second request
8 observers: number // how many mounted views render this key right now
9}
10
11const isStale = (e: Entry<unknown>, staleTime: number) =>
12 Date.now() - e.updatedAt > staleTime
13
14// Two clocks, deliberately independent:
15//
16// staleTime -> may this be shown without checking? (a correctness policy)
17// gcTime -> may this be dropped once nothing shows it? (a memory policy)
18//
19// An entry can be stale for an hour and still worth keeping: stale data is the
20// fastest first frame you have. Setting gcTime from staleTime is the mistake —
21// it evicts the thing you were about to render.

The observers count is not bookkeeping. It decides whether an invalidation refetches now or merely marks the entry for the next time it is read, and it is what the retention clock waits on. A cache without it either refetches data nobody is looking at or drops data somebody is.

How to build it

Most important first.

  • Decide the keys first. The key is the data's identity and every other behaviour — hits, deduplication, invalidation blast radius — falls out of it (Query Keys and Invalidation).
  • Set the staleness window per query, from how fast that data actually changes and what showing a wrong value costs. A single global number is a decision not to decide.
  • Treat server data as a distinct category of state with a distinct owner, rather than as component state that happens to arrive over the network (Server State Is Not Your State, The Seven Kinds of State).
  • Keep one copy. Views read from the cache; they do not copy into local state, because a copy is a fork with no merge strategy.
  • Set retention deliberately, and bound it. An entry per filter combination per page is a lot of entries in a session that never reloads (Long-Lived Clients and Version Skew).
  • Say out loud which layer you are configuring. "Add caching" means at least four different things — HTTP headers, a CDN, a client cache, a service worker — and they compose rather than substitute (Caching Strategies, CDN Delivery).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A cache hit renders with no loading state, which is a genuine accessibility win: there is nothing to announce, and no busy interval to sit through. Preserve that by not showing a skeleton for data you already have.
  • When a cached render is later replaced by fresher data, that is a content change under a reader. It must be announced politely and it must not move focus (Live Regions and Announcement, Stale-While-Revalidate).
  • Keep node identity stable across cache writes so the focused element survives the update. Re-keying a list on every response destroys and recreates the DOM nodes, and focus lands on body (Node Identity Across Updates, Reconciliation and Keys).
  • If you knowingly render data that is old — offline, or a failed revalidation — say so in text that assistive technology reaches, not only as a greyed-out visual treatment (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • Unbounded growth: every key the user has ever visited is retained because retention was never configured, and a long session ends with the whole dataset in memory.
  • A persisted cache restored after a deploy that changed the response shape. The UI renders yesterday's field names and crashes on a property that no longer exists (Long-Lived Clients and Version Skew).
  • The cache survives a user switch, so the second user sees the first user's data until something happens to refetch it.
  • The staleness window is set globally and generously, so every screen is subtly out of date and users learn to reload the page — the exact behaviour the cache was meant to remove.
  • Your own deduplication misfires: two requests are merged because the key omitted a parameter that differed, so one caller gets an answer to someone else's question.
What can arrive out of order
  • Two components mount in the same task and both request the same key. Without deduplication two requests go out, and whichever resolves last wins — which is not necessarily the one that was sent last (Out-of-Order Responses).
  • A refetch resolves after the entry it belongs to was garbage-collected or after its key stopped being rendered. Writing the result back resurrects an entry nobody wanted and keeps it alive.
  • A mutation commits between the cache read and the render, so the frame that reaches the screen is one version behind the cache it was read from (Optimistic UI).
  • Two tabs mutate the same resource. Neither cache hears about the other, so both are confidently wrong until something triggers a revalidation (State Synchronization).
Security
  • The cache is an ordinary object in the page's heap. Any script running on the origin can read all of it, which means an XSS on any page of your app is an exfiltration of everything the user has looked at this session (Cross-Site Scripting).
  • The key must include the identity the request was made as. A cache that outlives a session or a tenant switch will happily serve the previous identity's data, and no server-side check is involved because no request is made (Session Expiry and the Refresh Race, Auth Across Tabs).
  • Persisting the cache moves server data onto a disk you do not control, where it outlives the session and survives the user walking away from the machine. Clearing it at logout is a requirement, not a nicety (Storage Security and Durability).
  • A cached entry containing a field the user should not see is not the cache's bug — the field was already sent over the wire. Authorization is decided at the server and nowhere else (What the Frontend Is Responsible For in Auth).
Misreads
  • "We already set Cache-Control, so the client cache is redundant." They sit at different layers. The HTTP cache can still be revalidating with a conditional request and a round trip, where a fresh client entry makes no request at all (Conditional Requests: ETags, 304 and 412).
  • "Stale means broken." Stale means "worth checking". Rendering stale data first is usually the correct behaviour; rendering it *without ever checking* is the failure.
  • "The cache is the source of truth." It is a replica. The server is the authority, and every design decision in this module is about what to do while the replica and the authority disagree.
  • "Setting a long staleness window is a performance optimisation." It is a correctness decision that happens to reduce requests. Say what it costs the user before you say what it saves the server.

Measuring it, and what changes in the field

How you would see this
  • The Network panel answers the first question: did a request happen at all? A client cache hit produces no row. An HTTP cache hit produces a row whose size column reads "(memory cache)" or "(disk cache)". That difference tells you which layer served the data (Debugging the Network).
  • Resource timing entries expose the same thing programmatically: a cached response reports a transferSize of zero — although so does a cross-origin response without Timing-Allow-Origin, so read the two together rather than treating zero as proof (Real User Monitoring).
  • Cache library devtools show the whole map at once: every key, its status, its age and how many observers it has. It is the fastest way to find a key that is subtly different from the one you meant to write (Debugging State).
  • The Memory panel gives the retained size of the cache and what is holding it, which is how you tell a cache doing its job from a leak wearing its clothes (Debugging Memory).
Slow device, slow network, large data, old tab
  • On a slow network, the cache is the experience. The difference between a hit and a miss is the difference between an instant screen and a spinner, and it dominates every other frontend decision on that screen (Loading, Error, Empty — The States You Did Not Render).
  • On a slow device, the parse and the re-render are the expensive parts, not the request. A large cached payload still costs a synchronous parse before anything renders.
  • With a large dataset, entries multiply combinatorially: one per filter, per sort, per page. Retention that was invisible with ten entries is a memory problem with ten thousand (Pagination From the Interface Backwards).
  • In an old tab, entries can be hours stale and the response shape may have changed under you between deploys. Both are normal, and neither is visible in development (Long-Lived Clients and Version Skew).
What this costs
  • A shared cache buys deduplication, instant back-navigation and one source of truth, and it costs you a second state system to reason about with its own lifecycle, its own bugs and its own devtools.
  • A long staleness window buys fewer requests and instant screens, and it costs correctness in exactly the moments a user notices — right after they or someone else changed something.
  • Persisting the cache buys an instant first screen on a cold start and costs you schema migration, storage limits, and a data-at-rest exposure you did not previously have (Choosing Browser Storage).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe model — a key, an entry, a freshness state, a revalidation trigger and a retention clock — is common to every client cache. The names differ: what one library calls stale another calls "needs revalidation", and what one calls garbage collection another calls eviction.
  • FRAMEWORK-SPECIFICTanStack Query stores one entry per hashed key with separate staleTime and gcTime clocks; SWR expresses the same thing as revalidation triggers plus a deduping interval; Apollo Client and Relay normalise responses into a graph of entities keyed by type and id, so "the entry" is a record many queries reference rather than a per-key blob; RTK Query keys by endpoint plus argument and tracks invalidation with tags. Assume none of these shapes when reasoning about the others.
  • BROWSER-SPECIFICBrowsers now partition the HTTP cache by top-level site to close a cross-site tracking channel, so a resource cached on one site is refetched on another. Chromium, Firefox and Safari all do this, but they adopted it at different times and partition on slightly different keys, so shared-CDN cache-hit assumptions do not hold uniformly.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencysingleflight
Domains that do not exist yet
  • Distributed Systems — the client cache is a replica of state whose authority lives on the server, so freshness, invalidation and conflict are the same problems replication has always had, seen from the tab.
OS & Networkinghttp-basics