open atlas
↑ Back to track
React, zero to senior RCT · 03 · 04

Cache invalidation patterns: invalidate vs write-through, optimistic rollback, and live sync

After a write, the cache lies until you reconcile it. Invalidate buys server truth for an extra request; setQueryData is instant but replicates server logic; optimistic updates need cancel + snapshot + rollback. Plus infinite-query refetch costs and websocket sync.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Two tickets, two weeks apart, same root cause family. Ticket one: “I created an invoice, got the success toast, landed on the invoice list — and my invoice isn’t there. Refreshed, there it is. Your app loses data.” It doesn’t lose data; the list query had staleTime: 5 minutes for performance, the mutation never invalidated ['invoices'], and the cache faithfully served its five-minute-old truth. Ticket two came from the infra side: API p99 latency spiking every afternoon. The trace showed single users generating 40 requests in one second — a “fix” for ticket one had added invalidateQueries() with no key to every mutation’s onSuccess, so saving one checkbox refetched every active query on a dashboard with forty widgets. A refetch storm, self-inflicted. This is the lesson where the two famous hard things meet: after every write, your cache is wrong until you reconcile it — and reconciliation is a dial between “refetch everything true” and “patch exactly what changed”, where one end costs requests and the other costs correctness.

Invalidate vs setQueryData: truth by refetch or truth by replication

invalidateQueries({ queryKey: ['invoices'] }) is the conservative move: prefix-match the affected keys, mark them stale, refetch the active ones. You get server truth — sort order, computed totals, rows other users added — at the price of one extra round trip and a brief window where the old data is still on screen. setQueryData(['invoices'], updater) is write-through: patch the cached value synchronously, zero requests, instant UI. Its price is sneakier: your updater must replicate server logic. Insert the new invoice — at which position? The list is sorted by a server-assigned number. Does it match the current filter? Did the server set dueDate, normalize the currency, trigger a side effect on another row? Every divergence is a silent lie in the cache. The senior default: write-through with the server’s response (onSuccess: (created) => setQueryData(...) patching the created object in), or write-through then invalidate — instant UI now, truth reconciled in the background. Reserve pure setQueryData for caches whose shape you fully control, and never invalidate without a key: scope it to the resource family that actually changed, or a 40-widget dashboard turns every checkbox into a request storm.

Optimistic updates: the three-step contract with a rollback

For interactions where even one round trip feels broken — likes, toggles, drag-reorder — update the cache before the server confirms. The contract has three mandatory steps, all in onMutate:

const mutation = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (todo) => {
    // 1. Cancel: an in-flight refetch would overwrite our optimistic write
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    // 2. Snapshot: what we roll back to if the server says no
    const previous = queryClient.getQueryData(["todos"]);
    // 3. Optimistic write-through
    queryClient.setQueryData(["todos"], (old) =>
      old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
    );
    return { previous };                      // → context
  },
  onError: (_err, _todo, context) =>
    queryClient.setQueryData(["todos"], context.previous), // rollback
  onSettled: () =>
    queryClient.invalidateQueries({ queryKey: ["todos"] }), // reconcile
});

Each step exists because of a specific failure. Skip cancel and a refetch that started before your click can land after your optimistic write and revert it mid-flight — the checkbox flickers off, then on again when the mutation settles; users file it as “the app fought me”. Skip snapshot/rollback and a server rejection leaves the UI permanently claiming something false — the like that never happened. Skip reconcile in onSettled and your optimistic guess (even a correct one) drifts from server-computed fields. The tradeoff is honest: optimistic UI converts p50 latency into zero perceived latency, but buys an error path you must design — rollback plus a visible “couldn’t save” affordance — and it composes badly with many concurrent mutations on the same entity (the snapshot of mutation B may capture mutation A’s unconfirmed guess; TanStack Query’s docs route heavy cases through per-mutation variables or single-flight mutations instead).

Quiz

An optimistic toggle implements snapshot and rollback but skips cancelQueries in onMutate. QA reports the checkbox sometimes flips back by itself a moment after clicking, then flips on again. What happened?

Beyond a single list: dependencies, pages, and pushed truth

Once you have the single-list patterns down, ask yourself: where does the data graph get more complex? Dependent queries, pagination, and real-time push each break one of the assumptions above — and each has its own failure cost that doesn’t show up until traffic or page count grows.

Dependent queries chain with enabled: useQuery({ queryKey: ['projects', user?.id], enabled: !!user }) keeps the second query idle until the first resolves. This is the deliberate version of last lesson’s waterfall — fine when the dependency is real, but every enabled chain is serialized latency you chose, so audit chains the way you audit await sequences. Infinite queries make invalidation expensive in a way that surprises: an invalidated useInfiniteQuery refetches every loaded page sequentially to keep cursors consistent — a user 10 pages deep triggers 10 serial requests per invalidation. Mitigations: cap retained pages with maxPages, prefer targeted setQueryData page-patching for single-row edits, and use placeholderData: keepPreviousData on ordinary paginated queries so page flips show the old page instead of a spinner. Websocket-driven sync is the endgame for multi-user staleness, and the senior pattern is unintuitive: on a push event, do not write the payload into the cache — call invalidateQueries with the event’s entity key. Pushed payloads arrive out of order, may omit server-computed fields, and duplicate your write-through logic; using events as “something changed, ask the server” signals keeps one source of truth, and at high event rates you debounce invalidations per key — collapsing 50 events/second into at most a few refetches — which is exactly the storm-throttling that ticket two needed.

Quiz

A realtime board receives websocket events for card changes and writes each event payload straight into the cache with setQueryData. Cards intermittently show stale titles and a missing computed "blocked" badge. What is the senior fix?

Recall before you leave
  1. 01
    Lay out the reconciliation dial after a mutation: the three strategies, what each costs, what each risks, and the production bug signature of getting each wrong.
  2. 02
    How do invalidation costs change for infinite queries, and why is 'invalidate on event' rather than 'write the payload' the standard pattern for websocket-driven cache sync?
Recap

A mutation makes every cached read of that data wrong, and this lesson is the catalogue of reconciliation strategies. Invalidation is truth by refetch: invalidateQueries prefix-matches a scoped key, marks entries stale, refetches active ones — its failure modes are under-invalidation (the created invoice missing from a five-minute-fresh list until F5) and over-invalidation (a keyless invalidate turning one checkbox into a 40-request storm and afternoon p99 spikes). Write-through setQueryData is truth by replication: instant and request-free, but the updater must mirror server logic — sort position, filter membership, computed fields — and every divergence lies silently, so prefer patching with the server’s actual response or following the write with an invalidate. Optimistic updates move the write before the server’s answer and are a three-step contract in onMutate: await cancelQueries so an in-flight refetch cannot stomp the optimistic value (the tell-tale flicker), snapshot for rollback in onError so rejection cannot leave permanent false UI, and invalidate in onSettled so even correct guesses reconcile with server-computed truth. Dependent queries via enabled are chosen waterfalls — audit them like await chains. Infinite queries refetch all loaded pages sequentially on invalidation, so cap pages and patch rows in place. Websocket events are signals, not data: invalidate the entity’s key (debounced under load) instead of writing payloads through, because pushed payloads arrive unordered and incomplete. The dial runs from “refetch everything” to “patch exactly this” — requests on one end, correctness risk on the other. Now when you write a mutation’s onSuccess, you reach for the narrowest key that covers what actually changed — and when you see a flicker or a stale list after a save, you know which of the three steps to audit first.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.