Serializing Concurrent Mutations to One Entity
Two clicks on the same toggle, half a second apart, produce two PATCH requests. They leave in order and arrive in whatever order the network decides, so the server may apply “off” after “on” and the switch settles wrong — with both mutations reporting success and no error anywhere. TanStack Query deduplicates queries but deliberately does not order mutations, because two mutations are genuinely two operations. This page covers making them ordered when they touch the same record, under Concurrency & Race Conditions. It assumes cancellation is already wired per Cancelling In-Flight Queries With AbortController, and the rollback mechanics from Rolling Back Optimistic Updates on Error.
Prerequisites
- TanStack Query v5, which introduced
scopeon mutation options. - A mutation that can plausibly be triggered twice before the first settles.
- A clear answer to whether intermediate states matter, or only the final one.
Step 1 — Scope Mutations by the Entity They Write
scope.id is the whole mechanism. Keying it on the entity keeps unrelated records parallel.
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useToggleTodo(todoId: string) {
const client = useQueryClient();
return useMutation({
// Same id ⇒ serialized, in call order. Different id ⇒ still parallel.
scope: { id: `todo-${todoId}` },
mutationKey: ['todos', 'toggle', todoId],
mutationFn: (done: boolean) =>
fetch(`/api/todos/${todoId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done }),
}).then((r) => {
if (!r.ok) throw new Error(`Toggle failed: ${r.status}`);
return r.json() as Promise<Todo>;
}),
onSuccess: (updated) => {
// Trust the server's row wholesale rather than merging fields.
client.setQueryData(['todos', 'detail', todoId], updated);
},
});
}
Cache Behavior Analysis: With a scope, the MutationCache starts the second mutation only after the first has settled, so the two PATCH requests never overlap and arrival order equals call order. Scoping per entity rather than per resource type is what preserves throughput: checking twelve different items in a list still issues twelve concurrent requests, because each has its own scope id, whereas scope: { id: 'todos' } would turn that into twelve sequential round trips. Mutations without a scope are unaffected and continue to run concurrently, so adopting this is incremental rather than a global behaviour change.
Step 2 — Take the Snapshot Inside onMutate
Once mutations queue, the state a rollback must restore is the state at run time, not at call time. Reading in onMutate is what makes that correct.
onMutate: async (done: boolean) => {
await client.cancelQueries({ queryKey: ['todos', 'detail', todoId] });
// Read HERE, not in the component. By the time this runs, the previous
// mutation in this scope has already committed its optimistic value, so
// this snapshot describes the state immediately before *this* write.
const previous = client.getQueryData<Todo>(['todos', 'detail', todoId]);
client.setQueryData<Todo>(['todos', 'detail', todoId], (current) =>
current ? { ...current, done } : current,
);
return { previous };
},
onError: (_error, _done, context) => {
if (context?.previous) {
client.setQueryData(['todos', 'detail', todoId], context.previous);
}
},
Cache Behavior Analysis: onMutate runs when the mutation actually starts — which, in a scope, is after its predecessor settles — so the snapshot it takes reflects every optimistic write that preceded it. Capturing previous in the component’s render instead would restore a value from before the whole burst, so a failure of the third toggle in a sequence would silently undo the first two as well. The context object returned from onMutate is passed to onError and onSettled for that specific mutation, so each queued mutation carries its own rollback point rather than sharing one.
Step 3 — Collapse the Queue When Only the Final Value Matters
Serializing five toggles still sends five requests. When intermediate states carry no meaning, collapse them instead.
import { useRef } from 'react';
export function useAutosaveTitle(todoId: string, delayMs = 400) {
const client = useQueryClient();
const timer = useRef<number>();
const mutation = useMutation({
scope: { id: `todo-${todoId}` },
mutationFn: (title: string) => renameTodo(todoId, title),
onSuccess: (updated) => client.setQueryData(['todos', 'detail', todoId], updated),
});
return {
...mutation,
save(title: string) {
// Paint immediately; the cache is the source of truth for the input.
client.setQueryData<Todo>(['todos', 'detail', todoId], (current) =>
current ? { ...current, title } : current,
);
window.clearTimeout(timer.current);
// Only the last value in a burst reaches the network.
timer.current = window.setTimeout(() => mutation.mutate(title), delayMs);
},
};
}
Cache Behavior Analysis: Writing to the cache on every keystroke while debouncing only the network call keeps the input responsive without queueing a request per character — the cache is doing the job of local component state, which also means the value survives navigating away and back. Keeping the scope is still worthwhile: the debounce collapses a burst, but two bursts separated by slightly more than the delay can still overlap, and the scope orders those. The trade is that intermediate values are never persisted, so a crash mid-burst loses everything since the last flush — acceptable for a title, not for a sequence of distinct commands.
Step 4 — Decide What a Failure Does to the Queue
A scope keeps running after a failure. For dependent edits that is usually wrong, and the correct behaviour has to be chosen deliberately.
const client = useQueryClient();
// Option A: let the queue continue. Correct when each mutation sends an
// absolute value that does not depend on its predecessor.
const absolute = useMutation({
scope: { id: `todo-${todoId}` },
mutationFn: (done: boolean) => setTodoDone(todoId, done),
});
// Option B: abandon the rest of the scope. Correct when mutations are
// relative — an increment, an append, a state machine transition.
const relative = useMutation({
scope: { id: `counter-${counterId}` },
mutationFn: (delta: number) => adjustCounter(counterId, delta),
onError: () => {
// Remove every still-pending mutation in this scope so a failed step
// cannot be followed by steps that assumed it succeeded.
client.getMutationCache()
.findAll({ status: 'pending', predicate: (m) => m.options.scope?.id === `counter-${counterId}` })
.forEach((mutation) => client.getMutationCache().remove(mutation));
// Then reconcile with the server rather than guessing.
void client.invalidateQueries({ queryKey: ['counters', 'detail', counterId] });
},
});
Cache Behavior Analysis: Removing pending mutations from the MutationCache drops them before they run, which is the only way to stop a queued relative operation from applying on top of a state its predecessor failed to produce. Following that with an invalidation is not optional: after abandoning a queue you no longer know what the server holds, and the cached optimistic values are, by construction, wrong. For absolute mutations none of this applies — a later “set done to false” is correct regardless of whether the earlier “set done to true” succeeded, which is a good argument for designing mutation payloads as absolute values wherever the domain allows it.
Edge Cases & Gotchas
Scope ids must be stable across renders. Building the id from an object or an array literal produces a new value each render and silently disables queueing. Template strings over primitives are safest.
Offline mutations queue anyway. With a persisted mutation cache, mutations dispatched offline are replayed on reconnect. Scopes are preserved through that replay, so a scoped burst still applies in order — see Syncing Offline Mutations on Reconnect.
Scoping hides slowness. A queue turns a latency problem into a throughput problem: five 300-millisecond writes take 1.5 seconds, and the UI must not appear frozen for the last one. Keep the optimistic write immediate so the queue is invisible.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A toggle settles in the wrong position | Two unscoped mutations arrived out of order | Add scope: { id } keyed on the entity |
| A failed write undoes earlier successful ones | The rollback snapshot was captured at render time | Read the snapshot inside onMutate |
| A list of checkboxes became sequential and slow | The scope id is the resource type, not the entity | Key the scope on the entity id |
| Queueing has no effect | The scope id is a new object each render | Build the id from primitives in a template string |
| A counter drifts after a failure | Queued relative mutations ran on top of a failed step | Remove pending mutations in the scope, then invalidate |
Frequently Asked Questions
What exactly does mutation scope do?
Mutations that share a scope.id run one at a time, in the order mutate was called — the second does not start until the first has settled, successfully or otherwise. Mutations with different scope ids, and mutations with no scope at all, are unaffected and continue to run concurrently. That granularity is the whole point: it lets you serialize exactly the writes that can conflict, without turning a parallel workload into a sequential one. Everything else about the mutation lifecycle is unchanged, including retries, which happen inside the scope’s turn.
Does a failed mutation block the ones queued behind it?
No — the queue moves on and the next mutation runs. Whether that is right depends entirely on whether your mutations are absolute or relative. A mutation that sends “done is false” is correct regardless of what happened before it, so continuing is fine. A mutation that sends “add one” assumes its predecessor applied, so running it after a failure produces a value nobody asked for. For the relative case you have to intervene explicitly: remove the still-pending mutations in that scope and invalidate, because after a broken chain the cache no longer knows what the server holds.
Is debouncing a better answer than serializing?
For a field where only the final value matters — a title, a search filter, a slider — yes, and it is strictly better: it sends one request instead of twelve, so there is no queue to order in the first place. Serializing is the right tool when every intermediate state is meaningful, such as a sequence of distinct commands or a state machine the server is tracking. The two also compose: debounce to collapse a burst, and keep the scope so that two bursts arriving close together still apply in order.
Related
- Concurrency & Race Conditions — the parent guide on overlapping requests and the guards that order them.
- Cancelling In-Flight Queries With AbortController — stopping superseded reads, which scopes deliberately do not do.
- Rolling Back Optimistic Updates on Error — the snapshot and rollback lifecycle a queue changes the timing of.
- Syncing Offline Mutations on Reconnect — how scoped ordering survives a replayed offline queue.