Query Keys and Invalidation
The key is the identity of the data. Deduplication, hits, cross-contamination and the blast radius of every invalidation are all decided by what you put in it.
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.
What exactly identifies this piece of server data, and what should happen to it when something changes?
Someone filters a list, edits one row, and expects the edit to be reflected — in the list, in the detail view, and in the count in the navigation — without reloading the page.
Key each query by its endpoint name — todos — and after any successful mutation, clear the whole cache so the next render fetches fresh data.
The filter is passed to the request but not into the key, so switching from "open" to "done" is a cache hit. The old rows render under the new filter with no loading state and no error. Nothing reports this; it is simply wrong (Derived State).
- The filter is passed to the request but not into the key, so switching from "open" to "done" is a cache hit. The old rows render under the new filter with no loading state and no error. Nothing reports this; it is simply wrong (Derived State).
- Clearing everything turns one save into a burst: the dashboard, the navigation counts, the profile, and every list the user has visited all refetch at once, on a device that was in the middle of an interaction (Long Tasks).
- The page number is in the URL but not in the key, so back-navigation to page three renders page one's rows under a "Page 3" heading (The URL Is Application State).
- The key contains a freshly-constructed object literal, so it hashes differently on every render. The cache never hits and the component fetches in a loop that looks, from the outside, like the server being slow.
- The key omits the identity the request was made as, so a tenant switch — or the same browser used by two people — shows the previous identity's rows.
What is actually happening
In the browser, not in the framework.
- A key is serialised to a stable string. Array keys are hashed with a deterministic stringify that sorts object properties, so
{ a: 1, b: 2 }and{ b: 2, a: 1 }are the same key — but1and"1"are not, which is why a route parameter read from the URL as a string and an id read from a payload as a number produce two entries for one resource. - Everything that varies the response goes in the key: the resource, the shape (list or detail), every filter, the sort, the page or cursor, the locale, and the identity of the caller (How API Shape Drives UI Complexity).
- Invalidation marks entries stale; it does not delete them. That distinction is the entire user-visible difference between a good invalidation and a bad one: a marked-stale entry keeps rendering while it refetches, a removed entry leaves a hole.
- Matching decides the blast radius. Hierarchical keys match by prefix, so invalidating
['todos']hits every todo query. Tag-based systems match by declared tags instead. Normalised caches do not invalidate by key at all — a mutation response containing an entity id updates every query that references that entity, and nothing is refetched. - Only entries with observers refetch immediately. Entries nobody is rendering are marked stale and refetch the next time they are read, which is why the blast radius of an invalidation is "matching entries × currently mounted", not "matching entries".
- Writing the mutation response into the entry — instead of invalidating and refetching — is the cheaper path and is available whenever the response is the updated resource (PUT vs PATCH).
What this makes the browser do
And which of it is avoidable.
- Every invalidated, mounted entry becomes a request. Browsers cap concurrent connections per origin on HTTP/1.1, so a burst queues; HTTP/2 multiplexes it onto one connection and moves the contention to the server instead (HTTP/2: Streams on One Connection).
- Each response is a synchronous
JSON.parseon the main thread, and a burst of them lands in consecutive tasks, which is felt as input delay rather than as slow loading (Interaction Responsiveness). - Each write notifies its subscribers, so a twelve-query invalidation is at least twelve render passes, each with its own style, layout and paint work for its region (The Cost of a Change).
- Key hashing runs on every render of every query. It is cheap, but a key containing a large object is stringified in full, on the main thread, every time.
The key is the request, written down
useQuery/queryKey shape shown is TanStack Query. SWR takes the key as the first argument to useSWR and hashes it the same way; RTK Query derives the key from the endpoint name and its argument, so the equivalent bug there is an argument object that is not stable; Apollo derives it from the document and its variables, where the equivalent bug is a variable that is read at request time but not declared.The rule is short enough to memorise: if it changes the response, it is in the key. Everything painful in this lesson comes from a parameter that reached the request but not the key, and the reason that happens is that the two are usually written in different places by different people at different times.
The fix is structural rather than disciplinary. One function per resource returns both, so they cannot drift. It costs a few lines and removes an entire class of silent wrongness — and it also gives you the invalidation vocabulary for free, because the same factory names the prefixes.
1type TodoFilters = { status: string; sort: string; page: number; locale: string }2 3const todoKeys = {4 all: () => ['todos'] as const,5 lists: () => [...todoKeys.all(), 'list'] as const,6 list: (f: TodoFilters) => [...todoKeys.lists(), f] as const,7 details: () => [...todoKeys.all(), 'detail'] as const,8 detail: (id: string) => [...todoKeys.details(), id] as const,9}10 11// Now the invalidation vocabulary is named, not improvised:12// todoKeys.all() every todo query — the widest hammer13// todoKeys.lists() every list, no details — after a create or delete14// todoKeys.detail(id) exactly one record — after editing that record15 16// Wrong in a way nothing reports:17useQuery({ queryKey: todoKeys.all(), queryFn: () => fetchTodos(filters) })18// ^^^^^^^^^^^^^^ says "all todos"19// ^^^^^^^ asks for "these todos"20// Switching filters is a cache hit. The previous filter's rows render under21// the new filter's heading. No error, no spinner, no failed request.22 23// Right:24useQuery({ queryKey: todoKeys.list(filters), queryFn: () => fetchTodos(filters) })The as const matters more than it looks: it makes the key a tuple type, so a key built with the wrong arity or the wrong order fails to compile instead of silently hashing to a new entry. That turns the module's most invisible bug into a build error (TypeScript in the Build).
Blast radius
After a mutation there are three available moves, and they differ by how much work they cause and how much correctness they buy. Writing the response into the affected entries is the cheapest and the most precise. Invalidating a narrow prefix is the common middle. Invalidating the root is the one that turns a single save into a burst.
The reason the root invalidation is so popular is that it is the only one that is obviously correct without thinking. That is a real advantage and worth saying plainly: a precise invalidation that misses a derived query is a bug, and a wide invalidation is only expensive. The recommendation is not "always be narrow" — it is "know which one you chose and why".
await saveTodo(todo) queryClient.invalidateQueries() // everything, everywhere // what the user gets: // 12 requests leave at once // every region on the screen enters a busy state // the list they were reading re-renders and may reorder // on a phone, the burst of parses is felt as input delay
const updated = await saveTodo(todo)
// 1. the server told you the new value — put it where it belongs
queryClient.setQueryData(todoKeys.detail(updated.id), updated)
// 2. lists contain this record and may reorder or refilter: they must refetch
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
// 3. the sidebar count is derived from data you did not just receive
queryClient.invalidateQueries({ queryKey: ['todo-count'] })
// 2 requests, one of them only if a list is mounted.
// The detail view updates with no request at all.The mutation response already contains the authoritative new value for the record, so refetching it is a round trip that asks a question you have just been answered. The list and the count are different: their contents depend on data the response did not include — ordering, filtering, and rows the client has never seen — so they genuinely must ask. Sorting responses into "I was told this" and "I would have to ask" is the whole technique.
When one save becomes twelve requests
A refetch storm is rarely designed. It accretes: a wide invalidation was correct when the app had four queries, and nobody revisited it as it grew to forty. The symptom appears first on mid-range phones and on slow connections, and it appears as sluggishness after saving rather than as anything anyone would file as a caching bug.
The rows below are the ways this shows up in practice, including the two failures of the fix itself — over-narrow invalidation, and the maintenance burden of a hand-maintained mutation-to-query map.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A save invalidates the root prefix | Every mounted region goes busy; the app stutters for a moment after each save | The match covers most of the cache, and every entry with an observer refetches immediately | Invalidate the narrowest prefix that covers what actually changed, and write the mutation response directly into the record it updated. |
| A filter is passed to the request but not the key | Switching filters shows the previous filter's rows, instantly and with no loading state | Both requests hash to one entry, so the second read is a hit | Build key and request from one factory; type the key as a tuple so arity mistakes fail to compile. |
| Invalidation is precise and a derived query was missed | The list is right, the count in the navigation is wrong until a reload | Aggregates depend on data the mutation response did not contain, and nothing declared that dependency | Treat derived and aggregate queries as explicit dependents of the mutation, or key them so a prefix match covers them (Derived State). |
| The key contains a new object literal each render | Constant refetching that looks like a slow server; the cache never hits | Hashing is deterministic over values, but a value rebuilt per render still hashes identically — unless it contains a timestamp, a function or a random id | Keep keys to primitives and plain data; never put callbacks, dates constructed at render time, or class instances in one. |
| Many tabs return to focus at the start of the working day | A synchronised request spike at the same minute each morning | Refetch-on-focus plus wide invalidation, multiplied by every open tab of every user | Jitter background revalidation, narrow the match, and treat the client's refetch policy as a server load decision (Thundering Herd). |
| A new query is added and nobody updates the mutation's invalidation list | One screen is stale after saving; every other screen is correct | The mapping between mutations and queries lives in developers' heads | Co-locate the invalidation list with the key factory for the resource, and cover it with a test that saves and asserts the dependent query refetched. |
How to build it
Most important first.
- Write the key and the request from one function, so they cannot drift. A key factory per resource is the smallest thing that reliably prevents the "parameter in the request, not in the key" bug.
- Structure keys from general to specific — resource, then shape, then parameters — so prefix matching gives you a usable invalidation vocabulary rather than an all-or-nothing switch.
- Include the identity dimension (user, tenant, locale) in the key, or clear the cache at every session boundary. Pick one and make it explicit (Session Expiry and the Refresh Race).
- Invalidate the narrowest thing that actually changed, and add a broader invalidation only when you can name the specific query it fixes.
- Prefer writing the mutation response into the affected entries over invalidating them, when the response contains the updated resource. It removes a round trip and removes a race (Rollback and Reconciliation).
- Stabilise keys derived from user input. A key per keystroke means an entry per keystroke, a request per keystroke, and a set of responses that can arrive in any order (Out-of-Order Responses).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- An invalidation-driven refetch replaces content under the user. Announce the result politely through a live region that was already in the DOM, and do not move focus — the user did not navigate (Live Regions and Announcement).
- When the refetch was triggered by the user's own save, separate the two announcements: "Saved" is the outcome they asked for, "List updated" is a consequence. Merging them makes a failed save sound like a successful one.
- Mark the refreshing region
aria-busyrather than replacing it with a skeleton. Replacing it destroys the focused node and drops focus tobody(Focus Management). - A count in the navigation that changes silently is invisible to a screen-reader user until they navigate to it. If the count matters, it belongs in the confirmation text, not only in the badge.
What can go wrong
- The refetch storm: one mutation invalidates a prefix that matches most of the cache, and the app issues a dozen requests simultaneously every time the user saves anything.
- The opposite failure, which is just as real: invalidation so precise that derived queries — counts, aggregates, dashboards, search indexes — are never invalidated and quietly stay wrong (Eventual Consistency in Practice).
- Cross-contamination from an incomplete key, where two genuinely different requests share one entry. This is the most damaging failure in the module because it has no symptom other than incorrect data.
- A key containing something unstable — a timestamp, a new array, a callback — so the cache never hits and every render refetches.
- The mitigation failing: you make invalidation precise, and now every new query needs someone to remember to add it to the right mutation's invalidation list. That list is code nothing verifies and nothing tests (Component Testing).
- An invalidation is issued while a request for that key is already in flight. The in-flight response contains pre-mutation data and lands *after* the invalidation, writing stale data into an entry that was just marked fresh. A correct cache stamps each fetch and discards results from a fetch that started before the invalidation (Out-of-Order Responses).
- Two mutations invalidate overlapping key sets. Their refetches interleave, and the entry ends up holding whichever response arrived last rather than whichever mutation committed last.
- The key changes while a request is in flight — the user typed another character — so the response belongs to an entry nobody is rendering, and the entry that is rendering has no request running for it (Cancelling a Request Nobody Is Waiting For).
- A refetch triggered by invalidation resolves after an optimistic update wrote to the same entry, reverting the optimistic value to the server's pre-mutation state (Optimistic UI).
- The identity a request was made as is part of the data's identity. Leave it out of the key and the cache will serve one user's data to another without ever contacting the server (Multi-Tenant Isolation).
- Keys are not access control. A key nobody can guess is still readable by any script on the origin, and the entry it points at was authorised by the server when it was fetched — not by the key (What the Frontend Is Responsible For in Auth).
- Never put a credential in a key. Keys are surfaced in devtools, in library debug output, in error reports and in persisted caches, all of which are places a token should not appear (Cookies vs Script-Readable Tokens).
- Clear the cache at logout and on session expiry. The alternative is that the entries stay in the heap, and any subsequent script on the origin can read them (Cross-Site Scripting).
- "The key is just a string to look things up by." The key is the identity of the data. Two requests with the same key are asserted to be the same question, and the cache will act on that assertion even when it is false.
- "Invalidating more is the safe default." It is the safe default for correctness and the unsafe default for load, latency and battery. A save that triggers twelve requests is a design decision, not a side effect.
- "Prefix matching means I should nest everything deeply." Nesting only helps when the levels correspond to things you actually invalidate together. A five-level key you always invalidate at the root is a one-level key with extra typing.
- "Clearing the cache after a mutation is equivalent to invalidating it." Clearing removes the data, so mounted views fall back to a loading state. Invalidating keeps rendering while it refetches. The user sees a very different screen.
Measuring it, and what changes in the field
- Count the requests one save produces. Open the Network panel, filter to XHR/fetch, clear it, save once. The number that appears is your blast radius, and it is usually larger than anyone on the team expects (Debugging the Network).
- Library devtools show which keys went stale and which refetched, which distinguishes "the invalidation matched too much" from "too many components were mounted" (Debugging State).
- In the field, a counter of requests per interaction is the signal that catches a blast radius that grew slowly as queries were added (Real User Monitoring).
- For cross-contamination there is no signal at all — no error, no failed request. The only defence is a test that switches a parameter and asserts the rendered output changed (Component Testing).
- On a slow network, a wide invalidation is felt as the whole screen going busy at once, because every region enters its loading state simultaneously.
- On a slow device, the parse and render work of a burst is what the user feels, not the requests. Twelve responses landing in twelve tasks is twelve chances to miss a frame (Long Tasks).
- With a large dataset, keys multiply: filter × sort × page is a product, and prefix invalidation over that product is a lot of entries (Pagination From the Interface Backwards).
- On the server side, a wide invalidation from every client at once is a load pattern, especially when combined with refetch-on-focus at the start of a working day (Cache Stampede: Everyone Misses at Once).
- Precise keys and precise invalidation give you a small blast radius and cost you a mapping between mutations and queries that must be maintained by hand as the app grows.
- A normalised cache removes most invalidation bookkeeping by identifying entities rather than requests, and costs you a normalisation layer, entity identity requirements on every response, and a much harder debugging story when a record updates unexpectedly.
- Writing mutation responses into entries removes a round trip and costs you an API contract commitment: every mutation must return enough of the resource to reconcile with (Response Contracts Are Not Database Rows).
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 principle — the identity of cached data is whatever varies its response, and the cost of invalidation is proportional to what the match covers — holds for any client cache, including one you write yourself with a
Map. - FRAMEWORK-SPECIFICTanStack Query uses array keys with prefix matching and
invalidateQueries; SWR matches keys with a predicate function passed to the globalmutate; RTK Query declaresprovidesTagsandinvalidatesTagsper endpoint rather than matching keys at all; Apollo Client and Relay normalise by entity identity, so a mutation returning the changed entity updates every query referencing it with no invalidation step. Advice written for one of these is often meaningless in another.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — invalidation is a cache-coherence problem with an unreliable link and no way to reach the other replicas, which is why "tell everyone who cares" is not an option a browser tab has.