Resolving Out-of-Order Responses With Request IDs
Inside a query library, a superseded response is discarded for you. Outside one — in a useEffect that fetches, a third-party SDK callback, a socket handler, a search box wired directly to a store — nothing does that, and the last response to arrive wins regardless of when it was asked for. This page covers the smallest guard that fixes it, under Concurrency & Race Conditions. Where you can cancel, do that as well — Cancelling In-Flight Queries With AbortController covers it — and where the ordering problem is between writes rather than reads, see Serializing Concurrent Mutations to One Entity.
Diagnostic Checklist
Prerequisites
- A write path you control — a
setQueryData, a store dispatch, asetState. - The ability to run code at request start as well as at completion.
- One counter per logical resource, not one for the whole application.
Step 1 — Allocate an Id at Request Start
The id must be taken before the request leaves, and captured in the closure that will commit its result.
export function createSequencedWriter<T>(commitValue: (value: T) => void) {
let issued = 0;
let accepted = 0;
return {
/** Call at request start. Returns the commit function for THIS request. */
begin() {
const id = ++issued;
return {
id,
commit(value: T): boolean {
// Strictly greater: an equal id is a duplicate delivery, not news.
if (id <= accepted) return false;
accepted = id;
commitValue(value);
return true;
},
/** True once a newer request has already been accepted. */
get superseded() {
return id <= accepted;
},
};
},
get pending() {
return issued - accepted;
},
};
}
Cache Behavior Analysis: Capturing id in the closure returned by begin() is what makes the guard immune to interleaving — each in-flight request carries its own identity rather than reading a shared variable at completion time, which would always see the latest value. Because accepted only ever increases, a response that lost the race can never win it later, including after a retry or after a reconnect replays it minutes afterwards. Returning false rather than throwing keeps the losing path quiet, which is correct: losing a race is the expected outcome for most superseded requests, not an error worth reporting.
Step 2 — Commit Through the Guard
Every write for that resource must go through commit. A single direct write elsewhere reintroduces the bug.
import { queryClient } from './query-client';
const searchWriter = createSequencedWriter<Todo[]>((rows) =>
queryClient.setQueryData(['todos', 'search'], rows),
);
export async function runSearch(term: string): Promise<boolean> {
const request = searchWriter.begin();
const rows = await fetch(`/api/todos?q=${encodeURIComponent(term)}`)
.then((response) => response.json() as Promise<Todo[]>);
// Returns false when a newer search has already landed.
return request.commit(rows);
}
// The guard also covers the error branch: a failure from a superseded request
// must not replace the results of a newer, successful one.
export async function runSearchSafe(term: string) {
const request = searchWriter.begin();
try {
const rows = await searchTodos(term);
request.commit(rows);
} catch (error) {
if (!request.superseded) queryClient.setQueryData(['todos', 'search', 'error'], error);
}
}
Cache Behavior Analysis: Guarding the error path matters as much as the success path and is the half people usually forget: a superseded request that fails will otherwise write an error over results that arrived successfully a moment earlier, so the screen shows a failure for a search that actually worked. setQueryData writes directly to the cache and notifies observers synchronously, so a rejected commit costs nothing at all — no write, no notification, no render. If several keys are written together, they must all be inside one commit call, or a partial write can interleave with a newer one and produce a mixed state.
Step 3 — Apply It to Effects and SDK Callbacks
The two places this pattern earns its keep are useEffect fetches and third-party callbacks that offer no cancellation.
import { useEffect, useRef, useState } from 'react';
export function useGeocode(query: string) {
const [result, setResult] = useState<GeoResult | null>(null);
// One counter per hook instance; survives re-renders, resets on remount.
const issued = useRef(0);
useEffect(() => {
if (!query) return;
const id = ++issued.current;
// A third-party SDK with a callback and no cancellation handle.
window.geocoder.lookup(query, (geoResult: GeoResult) => {
// Only the most recently issued lookup may write.
if (id !== issued.current) return;
setResult(geoResult);
});
}, [query]);
return result;
}
Cache Behavior Analysis: Comparing against issued.current rather than a captured accepted value is the idiomatic React form of the same guard, and it works because a ref is stable across renders while the captured id is per effect run. This is also the correct pattern for the classic useEffect fetch, where the alternative — a cancelled boolean set in the cleanup function — handles unmount but not a rapid prop change that starts a second request before the first resolves. Note the guard belongs inside the callback, not around the SDK call: the request has already gone, and the only thing you still control is whether its result is allowed to land.
Step 4 — Expose Pending Depth for Loading State
A boolean loading flag is wrong under concurrency: the first response clears it while a second request is still running.
export function useSequencedSearch() {
const [rows, setRows] = useState<Todo[]>([]);
const [pending, setPending] = useState(0);
const issued = useRef(0);
const accepted = useRef(0);
const search = useCallback(async (term: string) => {
const id = ++issued.current;
setPending((n) => n + 1);
try {
const result = await searchTodos(term);
if (id > accepted.current) {
accepted.current = id;
setRows(result);
}
} finally {
// Decrement regardless of whether this request won — the count tracks
// requests in flight, not requests that mattered.
setPending((n) => n - 1);
}
}, []);
// Correct under concurrency: true while ANY request is outstanding.
return { rows, isLoading: pending > 0, search };
}
Cache Behavior Analysis: Counting rather than flagging means the indicator stays on until every outstanding request has settled, which matches what the user perceives — they typed three characters and are waiting for the result of the third, not the first. Decrementing in finally keeps the count honest across rejections; a decrement only on success leaks the counter upward on every failure until the spinner never turns off. This is the same distinction TanStack Query draws internally between status and fetchStatus, and the reason its isFetching remains true while any fetch for a query is outstanding.
Edge Cases & Gotchas
Counter reset on remount. A useRef counter resets when the component remounts, which is correct — a new mount is a new logical sequence — but it means an in-flight request from the previous mount can be accepted by the new one. Guard on unmount as well by setting a disposed flag.
Several keys written together. If one response updates a list and a summary, both writes must be inside one commit. Splitting them lets a newer response interleave and leave the two disagreeing.
Server-generated ids. For socket streams the ordering must come from the server, because a client counter cannot order events the server produced — see Handling Out-of-Order Realtime Updates.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Results for a previous term appear briefly | No guard on the write boundary | Route every write through commit |
| A failure from an old request replaces good results | The error branch is unguarded | Check superseded before writing an error |
| The spinner clears while a request is still running | Loading state is a boolean | Count outstanding requests instead |
| The guard rejects everything after a while | The counter was compared with >= instead of > on issue |
Increment on issue, compare with > on commit |
| One resource’s slow request suppresses another’s | A single global counter | Use one counter per logical resource |
Frequently Asked Questions
Why not use a timestamp instead of a counter?
Because two requests issued in the same millisecond receive the same timestamp, and the comparison then cannot order them — which is precisely the situation you are trying to resolve, since rapid input is what produces overlapping requests in the first place. performance.now() has sub-millisecond resolution and would mostly work, but a counter is simpler, cannot be affected by clock adjustments, and makes the intent obvious to the next reader. The only case for a timestamp is when the ordering must be compared across processes, and there the right answer is a server-assigned value.
Should the counter be global or per resource?
Per resource, always. A single global counter means a slow request for the user directory can suppress a fast, newer request for the todo list, because the guard sees a lower id and rejects it — a correctness bug introduced by the guard itself. One counter per logical resource keeps the comparison meaningful: “is this newer than the last thing we accepted for this resource”. In practice that usually means one writer instance per query key prefix, created alongside the cache access it protects.
Does this replace cancellation?
No — they solve different halves and the halves are both worth having. Cancellation stops the wasted work: the request is aborted, the connection is freed, the server stops processing. A sequence guard does nothing about any of that; it only stops the wasted result from being applied. Even with cancellation wired perfectly there is a window where an abort is issued after the response has already been delivered to the callback, which is why the measurement in Step 2 shows cancellation alone still producing a handful of wrong renders. Where you can do both, do both; where you can only do this, this is the one that guarantees correctness.
Related
- Concurrency & Race Conditions — the parent guide on overlapping requests and the guards that order them.
- Cancelling In-Flight Queries With AbortController — stopping the work that this guard only stops the result of.
- Handling Out-of-Order Realtime Updates — the same idea with server-assigned versions for socket streams.
- Serializing Concurrent Mutations to One Entity — the write-ordering counterpart to this read guard.