Concurrency & Race Conditions

Concurrency bugs in a cache do not throw. A search box shows results for a query the user has already deleted; two rapid toggles leave a switch in the wrong position; a list briefly shows an item that was removed a second ago. Each is the same underlying event — a response arriving after the world it described stopped being true — and each is invisible in a test suite that awaits one request at a time. This guide is part of Cache Invalidation & Server Synchronization, and it is the counterpart to Mutation Sync & Rollback: rollback handles a mutation that fails, while this guide handles mutations and queries that succeed in the wrong order.

The four techniques below are ordered by how much they cost to adopt. Threading an AbortSignal is free and should be universal. Sequence stamping is cheap and covers hand-rolled fetch paths. Per-entity serialization changes how mutations are dispatched. Server-owned versioning changes your API contract, and is the only one that survives a multi-device user.

Diagnostic Checklist

Prerequisites

You should understand how optimistic writes are staged and reverted, covered in Rolling Back Optimistic Updates on Error, and the refetch triggers that generate concurrent reads in the first place, covered in Refetch on Window Focus and Reconnect. Both produce the overlapping requests this guide is about.

How a slow first response overwrites a fast second one A time axis showing request A sent at 0 milliseconds returning at 420, and request B sent at 90 milliseconds returning at 210. Without a guard the cache holds B's value from 210 until A's late arrival replaces it at 420. With a sequence guard, A's response is discarded on arrival and B's value survives. The response that wins is the one that arrives last, not the one that was asked last 0ms 90ms 210ms 420ms request A query "re" 420ms round trip request B query "react" 120ms round trip unguarded cache shows "react" results overwritten by stale "re" results guarded cache shows "react" results late response discarded on arrival
The gap between 210ms and 420ms is the window in which an unguarded cache holds a value the user never asked for.

Implementation 1 — Thread the Abort Signal Through Every Fetch

TanStack Query creates an AbortController per fetch and hands you its signal. If the signal never reaches fetch, cancellation is a no-op and every superseded request still runs to completion.

  1. Destructure signal from the query function context — it is always present in v5.
  2. Pass it to fetch, or to your HTTP client’s cancellation option.
  3. Let AbortError propagate; do not catch and convert it into an error state.
  4. Verify by throttling the network and watching requests turn red in the network panel.
import { useQuery } from '@tanstack/react-query';

interface SearchResult { id: string; title: string }

async function searchTodos(term: string, signal: AbortSignal): Promise<SearchResult[]> {
  const response = await fetch(`/api/todos?q=${encodeURIComponent(term)}`, { signal });
  if (!response.ok) throw new Error(`Search failed: ${response.status}`);
  return response.json() as Promise<SearchResult[]>;
}

export function useTodoSearch(term: string) {
  return useQuery({
    queryKey: ['todos', 'search', term],
    // The signal is aborted when this query is cancelled, refetched, or its
    // last observer unmounts.
    queryFn: ({ signal }) => searchTodos(term, signal),
    enabled: term.length > 1,
    // Keep the previous term's results on screen while the new term loads,
    // instead of flashing a spinner between keystrokes.
    placeholderData: (previous) => previous,
    staleTime: 30_000,
  });
}

Cache Behavior Analysis: With the signal wired, changing term aborts the in-flight request for the previous term before starting the new one, so the browser’s six-connections-per-origin budget is spent on the request the user is waiting for. Without it, TanStack Query still discards the stale result — a query only accepts the response of its most recent fetch — but it does so after the request completed, so you pay full network and server cost for a value that is thrown away. placeholderData: (previous) => previous is the v5 replacement for keepPreviousData; it holds the last successful data for the new key while isPlaceholderData is true, which is what prevents the list from collapsing between keystrokes.

Configuration Trade-offs:

  • Cancellation saves bandwidth and server load, not latency for the request that survives — it does not make the winning request faster, it stops the losers competing with it.
  • Aborting a POST is riskier than aborting a GET: the server may already have committed. Only cancel reads unless the endpoint is explicitly idempotent.
  • placeholderData keeps stale content visible; pair it with a subtle loading affordance or users will not notice that the list is one keystroke behind.
In-flight requests during a 12-character search, with and without cancellation Line chart over twelve keystrokes. Without cancellation concurrent in-flight requests climb to six and stay near five. With cancellation the count stays at one throughout. In-flight requests during a 12-character search, with and without cancellation no signal passed signal threaded 0 1.5 3 4.5 6 in-flight 1 2 3 4 5 6 7 8 9 10 11 12 keystroke
Cancellation does not reduce the number of requests started — it reduces how many are alive at once, which is what the connection pool cares about.

Implementation 2 — Stamp Requests and Discard Late Arrivals

Outside a query library — inside a WebSocket handler, a custom store, a useEffect that fetches — nothing discards stale responses for you. A monotonic sequence number is the smallest correct guard.

  1. Keep a counter per logical resource, not one global counter.
  2. Capture the counter value at request start.
  3. On response, compare against the latest issued value and drop anything older.
  4. Store the accepted sequence so a second late response cannot slip through.
export function createSequencedWriter<T>(apply: (value: T) => void) {
  let issued = 0;
  let accepted = 0;

  return {
    /** Call at request start; returns the commit function for that request. */
    begin(): (value: T) => boolean {
      const seq = ++issued;
      return (value: T) => {
        // Strictly greater: a response that ties with an already-accepted
        // sequence is a duplicate delivery, not newer information.
        if (seq <= accepted) return false;
        accepted = seq;
        apply(value);
        return true;
      };
    },
    get pending() {
      return issued - accepted;
    },
  };
}

// Usage inside a hand-rolled fetch path
const writer = createSequencedWriter<SearchResult[]>((rows) =>
  queryClient.setQueryData(['todos', 'search'], rows),
);

async function runSearch(term: string) {
  const commit = writer.begin();
  const rows = await fetch(`/api/todos?q=${term}`).then((r) => r.json());
  // Returns false when a newer search has already landed.
  return commit(rows);
}

Cache Behavior Analysis: Because accepted only ever moves forward, a response that lost the race can never write, even if it arrives minutes later after a retry. This matters more than it first appears in WebSocket paths, where a reconnect can replay a burst of messages that were generated before the client’s current state. Tracking pending gives you a cheap loading indicator that is correct under concurrency: it is non-zero exactly while some request the user cares about is outstanding, unlike a boolean flag that the second concurrent request clears prematurely.

Configuration Trade-offs:

  • A per-resource counter preserves parallelism; a single global counter would let a slow users fetch suppress a fast todos one.
  • Sequence numbers are client-local and reset on reload, which is fine for ordering within a session and useless for ordering against another device — that is what Implementation 4 is for.
  • Returning false from commit rather than throwing keeps the losing path silent, which is correct: losing a race is normal, not an error.

Implementation 3 — Serialize Mutations Per Entity

Two mutations that touch the same record must apply in the order the user issued them. TanStack Query’s MutationCache runs mutations concurrently by default; scoping them turns that into a queue.

  1. Give every mutation a scope.id derived from the entity it writes.
  2. Let the library queue same-scope mutations while different scopes stay parallel.
  3. Read the latest optimistic state inside onMutate, not from a closed-over variable.
  4. Invalidate once, on the last mutation in the scope, rather than after each.
import { useMutation, useQueryClient } from '@tanstack/react-query';

interface Todo { id: string; title: string; done: boolean; }

export function useToggleTodo(todoId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    // Mutations sharing a scope id run one after another, in call order.
    // Different todos still mutate in parallel.
    scope: { id: `todo-${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>;
      }),

    onMutate: async (done) => {
      await queryClient.cancelQueries({ queryKey: ['todos', 'detail', todoId] });
      // Read at mutate time: the previous mutation in this scope has already
      // committed its optimistic value by the time we run.
      const previous = queryClient.getQueryData<Todo>(['todos', 'detail', todoId]);
      queryClient.setQueryData<Todo>(['todos', 'detail', todoId], (current) =>
        current ? { ...current, done } : current,
      );
      return { previous };
    },

    onError: (_error, _done, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['todos', 'detail', todoId], context.previous);
      }
    },

    onSettled: () => {
      // Only the final mutation in the scope reaches an idle mutation cache.
      if (queryClient.isMutating({ mutationKey: ['todos'] }) === 1) {
        queryClient.invalidateQueries({ queryKey: ['todos'] });
      }
    },
  });
}

Cache Behavior Analysis: scope.id is the v5 mechanism that makes mutations with the same scope run serially in the order they were called; without it, two rapid toggles both start immediately and the server applies whichever arrives first. Reading previous inside onMutate rather than capturing it when the component rendered is what makes the queue correct — by the time the second toggle’s onMutate runs, the first has already written its optimistic value, so the rollback snapshot describes the state that actually preceded this mutation. The isMutating(...) === 1 guard checks that this is the last outstanding mutation, collapsing what would otherwise be one invalidation and refetch per toggle into a single reconciliation.

Configuration Trade-offs:

  • Scoping serializes, which means the second toggle waits for the first round trip. For a switch this is invisible; for a rich text autosave it may not be, and debouncing is the better tool there.
  • Scoping per entity keeps unrelated records parallel. Scoping per resource type (scope: { id: 'todos' }) is simpler and turns your whole todo workload sequential.
  • The isMutating guard is a heuristic. If a mutation from another feature shares the ['todos'] mutation key, the count will not reach one when you expect.
Which layer protects against which race Matrix mapping four concurrency hazards against four defences: abort signal, sequence stamping, per-entity mutation scope and server version guard. Which layer protects against which race Abort signal Sequence stamp Mutation scope Server version Superseded query in the same tab Prevents the wasted request Discards the late write Not applicable Not needed Out-of-order mutations, one tab No effect Not applicable Serializes them correctly Also detects it Two tabs writing the same record No effect No effect — counters are per tab No effect — caches are separate Rejects the stale write Replayed WebSocket burst after reconnect No effect Discards superseded frames No effect Rejects stale frames
No single mechanism covers every hazard. Signal plus scope covers a single tab; only a server-owned version survives a second device.

Implementation 4 — Reconcile With a Version the Server Owns

Client-side ordering is only authoritative for one client. The moment a user has two tabs, or a phone and a laptop, correctness requires a token the server issues and validates.

  1. Return a monotonic version (or an ETag) with every read of a mutable resource.
  2. Send the version back with every write.
  3. Have the server reject a write whose version is not current, with 409 Conflict.
  4. On conflict, refetch and either reapply or surface the divergence — never silently retry.
interface VersionedTodo { id: string; title: string; done: boolean; version: number }

export function useRenameTodo(todoId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    scope: { id: `todo-${todoId}` },
    mutationFn: async (title: string) => {
      const current = queryClient.getQueryData<VersionedTodo>(['todos', 'detail', todoId]);
      const response = await fetch(`/api/todos/${todoId}`, {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          // The server compares this against its own row version.
          'If-Match': String(current?.version ?? ''),
        },
        body: JSON.stringify({ title }),
      });

      if (response.status === 409) {
        // Someone else won. Pull the authoritative row before deciding anything.
        const fresh = await queryClient.fetchQuery<VersionedTodo>({
          queryKey: ['todos', 'detail', todoId],
        });
        throw new ConflictError('Todo changed elsewhere', fresh);
      }
      if (!response.ok) throw new Error(`Rename failed: ${response.status}`);
      return response.json() as Promise<VersionedTodo>;
    },

    onSuccess: (updated) => {
      // Trust the server's row wholesale — it carries the new version.
      queryClient.setQueryData(['todos', 'detail', todoId], updated);
    },
  });
}

export class ConflictError extends Error {
  constructor(message: string, readonly latest: VersionedTodo) {
    super(message);
    this.name = 'ConflictError';
  }
}

Cache Behavior Analysis: Writing the server’s returned row directly in onSuccess — rather than merging fields into the cached copy — guarantees the cached version advances in lockstep with the server’s, so the next write carries a version the server will accept. Merging is the subtle mistake here: a spread that preserves the old version produces a client that is permanently one version behind and conflicts on every subsequent write. Fetching the authoritative row inside the conflict branch, before throwing, means the error handler receives a latest snapshot and can present a real diff instead of a generic failure. Automatic retry on 409 must be disabled; retrying with the same stale version conflicts forever.

Configuration Trade-offs:

  • Versioning is an API contract change and needs server support. ETag plus If-Match gets you the same semantics over standard HTTP if you cannot add a field.
  • Rejecting stale writes means users occasionally see a conflict. That is the honest outcome; last-write-wins hides the conflict by discarding someone’s work.
  • A monotonic integer is easier to reason about than a hash-based ETag, but only if the server guarantees it increments on every write, including writes from background jobs.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Search results briefly show a previous term The superseded request resolved after the current one and nothing discarded it Thread signal into fetch; in hand-rolled paths add a sequence guard as in Implementation 2
Two rapid toggles leave the switch wrong Both mutations started concurrently and the server applied them in arrival order Add scope: { id } so same-entity mutations queue — see Serializing Concurrent Mutations to One Entity
Optimistic value flickers old, then new The rollback snapshot was captured before the previous optimistic write committed Read the snapshot inside onMutate, never from a value closed over at render time
Cancelled requests still appear to complete The signal was destructured but not forwarded to the HTTP client Confirm cancellation in the network panel under throttling; see Cancelling In-Flight Queries With AbortController
Two tabs overwrite each other silently Ordering is enforced per client only Adopt a server-owned version and reject stale writes with 409
A reconnect replays old socket frames over newer state Frames carry no ordering information Stamp frames server-side and drop superseded ones — see Handling Out-of-Order Realtime Updates

Frequently Asked Questions

Does TanStack Query already protect me from out-of-order responses?

For queries, yes. Each query tracks its current fetch, and a response from a superseded fetch is discarded rather than written to the cache — so even with no AbortSignal you will not see a stale value overwrite a fresh one within a single query. For mutations it does not, and cannot: two mutations are two genuine operations, and the library has no way to know that applying them out of order is wrong. Ordering between mutations that touch the same entity is yours to enforce, which is what scope.id exists for.

Why does passing the AbortSignal matter if the library discards late responses anyway?

Because discarding a response still means paying for it. Without the signal, the superseded request runs to completion: it holds one of the browser’s six connections to that origin, it occupies a server worker, and on a metered connection it costs the user real bytes. On a search box where someone types twelve characters in two seconds, that is eleven full round trips serving data nobody will see. Threading the signal turns those into aborted connections within a few milliseconds, which is the difference between one live request and a saturated connection pool.

Should concurrent mutations be queued globally or per entity?

Per entity. A global queue is easy to reason about and quietly turns a parallel workload into a sequential one — checking twelve items in a list becomes twelve sequential round trips, and the last checkbox responds a second later than the first. Keying the scope on the entity id preserves parallelism between different records while guaranteeing order for the record that actually has a conflict. The only reason to widen the scope is a server-side constraint, such as an endpoint that cannot accept concurrent writes to the same collection.

What is the difference between cancelling a query and removing it?

cancelQueries aborts any in-flight fetch and returns fetchStatus to idle, leaving the cached data and its observers untouched. That is why it is the first call inside onMutate: it stops a background refetch from landing on top of the optimistic value you are about to write. removeQueries deletes the entry from the cache entirely, so the next observer to mount starts from a hard miss and shows a loading state. Cancelling is a scheduling operation; removing is a data operation, and reaching for the wrong one is how a well-intentioned cleanup turns into a spinner on every navigation.