Race conditions and AbortController
Article.
The user types ab, then abc. Two requests go out. ab’s response comes back
second and the list now shows results for a query the user has already moved
past.
Everyone knows the fix is “use AbortController”. That answer is incomplete,
and the way it is incomplete is the actual interview question, because abort
is best-effort and the bug is a correctness problem.
The mechanism: the network is not ordered
The bug is not the network being slow. It is code that assumed responses arrive in the order the requests left.
fire A ("ab") ────────────────────────────►
fire B ("abc") ──────►
arrive B A
│ │
└─ set(B) └─ set(A) <- winsNothing guarantees ordering. HTTP/2 multiplexes, different queries take different amounts of server work, one request hits a cold cache, a proxy retries. The moment two requests can be in flight, “last response wins” is wrong — you wanted “latest request wins”.
That reframing tells you where the fix belongs: not on the network, but on the state update. The question every response must answer before it is allowed to write state is “am I still the one being waited for?”
// BROKEN - every response writes, whenever it lands
useEffect(() => {
fetch(`/api/search?q=${q}`).then(r => r.json()).then(setResults);
}, [q]);Abort is the optimisation, not the fix
AbortController cancels the request and rejects its promise:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${q}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(e => { if (e.name !== "AbortError") throw e; });
return () => controller.abort(); // runs BEFORE the next effect
}, [q]);The cleanup ordering is what makes this work: React runs the previous effect’s cleanup before the next effect body, so A is aborted the instant B starts, and unmount aborts whatever is pending.
But abort does not guarantee the response never lands. If A’s bytes are
already arriving, abort() can lose that race, and the .then may run. It is
a race against a race, which is exactly the kind of fix that works on your
machine and fails on a customer’s.
The generation check is the fix
function useSearch(q: string) {
const [results, setResults] = useState<Item[]>([]);
const gen = useRef(0);
useEffect(() => {
const mine = ++gen.current; // claim a ticket
const controller = new AbortController();
fetch(`/api/search?q=${q}`, { signal: controller.signal })
.then(r => r.json())
.then(data => {
if (mine === gen.current) setResults(data); // still current?
})
.catch(e => { if (e.name !== "AbortError") throw e; });
return () => controller.abort();
}, [q]);
return results;
}Two mechanisms doing two different jobs:
| Purpose | If you skip it | |
|---|---|---|
| Abort | stop wasted work | bandwidth and server load |
| Generation | guard the write | the bug is still there |
Only the generation check is load-bearing for correctness. Abort saves a round trip and frees a connection, which is worth having — but a candidate who names only abort has not understood why the bug happens.
Or let the response identify itself
The version with no counter at all, and often the cleanest:
const data = await res.json();
if (data.query !== qRef.current) return; // the response says what it answers
setResults(data.items);If the API echoes the query it answered, the client can check directly. It survives remounts, does not depend on a ref surviving anything, and it is self-describing in the network tab. Worth asking for when you control the API.
Debounce, throttle, abort
They solve different problems and the confusion between them is common.
| Effect | Use for | |
|---|---|---|
| Debounce | wait for N ms of quiet | typeahead request volume |
| Throttle | at most once per N ms | scroll, resize, pointermove |
| Abort | cancel the previous in-flight | navigation, superseded queries |
A production typeahead uses debounce plus abort plus a generation check, and each earns its place: debounce cuts the request count, abort frees the connection, the generation check is what makes it correct.
Note that debounce alone does not fix the race. If server latency varies by more than the debounce window — and it does, across a p50 and a p99 — two requests are still in flight and can still land out of order.
Beyond the typeahead
The same shape appears wherever a slower answer can arrive after a faster one:
- Tab or filter switches. Click “Archived”, click back to “Active”, the archived list lands second.
- An optimistic update overwritten by an in-flight refetch that started before the mutation.
- Two mutations to the same resource, where the second commits and the first response repaints the older value.
- Pagination, where page 2 arrives after page 3 and the list rewinds.
Whenever a component writes state from an async result, the question is the same one: is this still the answer we are waiting for?
What abort does not do
It does not cancel the server’s work
Aborting closes the client’s side. The server, unless it checks, finishes the query, writes the row, and charges the card.
# FastAPI 0.134+: a path operation that yields streams JSON Lines
@app.get("/report")
async def report():
async with db.stream(SCAN_QUERY) as rows: # closed if the client leaves
async for row in rows:
yield rowOn uvicorn, a disconnect cancels the response generator: CancelledError
lands at its current await, and the async with closes the cursor, so the
database stops too. await request.is_disconnected() is only a fallback for
loops that never reach an await. The full chain, down to an LLM provider,
is in
Streaming LLM responses over SSE.
For read endpoints this is a cost concern. For writes it is a correctness one:
aborting a POST does not undo it. The request may have committed before
the abort arrived, so the client now has no idea whether it happened. That is
the same uncertainty a timeout creates, and it has the same answer — an
idempotency key, so a retry is safe. See
Idempotency keys.
It does not un-queue a state update
If setResults already ran, abort is irrelevant — React has the update. This is
the same point as above from the other side: guard the write, do not rely on
cancelling the thing that leads to it.
Signals worth knowing
// Timeout with no manual plumbing.
await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
// Abort on EITHER the user cancelling or the timeout.
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(5000)]);
await fetch("/api/slow", { signal });AbortSignal is not a fetch feature — it is a general cancellation protocol.
addEventListener accepts one (a neat way to remove listeners in bulk), as do
modern stream APIs.
Where it breaks in production
Every cancellation logged as an error
The single most common consequence of shipping this pattern:
.catch(e => {
if (e.name === "AbortError") return; // not an error; it is the design
report(e);
});Without the filter, a fast typist generates a dozen AbortErrors per search and
the error dashboard becomes noise. Then someone mutes the channel, and the real
errors go with it.
.json() throws after the fetch resolved
await fetch(...) resolving does not mean the body arrived. .json() reads the
body stream, and that stream respects the signal — so an abort between the two
throws inside the parse, not at the fetch. Wrap the whole sequence, not just the
fetch call.
Disabling StrictMode to “fix” the double fetch
React’s dev-mode double invocation mounts, cleans up, and mounts again. If the request is not aborted in cleanup, you see two requests and it looks like a bug in React.
It is not — it is StrictMode successfully finding your missing cleanup. Turning it off hides the defect and keeps it. Fix the cleanup.
An unstable dependency aborting every render
useEffect(() => { /* fetch */ }, [{ q, page }]); // new object every renderA fresh object or array literal in the dep array is never equal to the previous one, so the effect re-runs, aborts its own in-flight request, and starts again. The symptom is a request that never completes and a spinner that never stops.
Depend on primitives, or memoize the object.
The generation counter that resets
Keeping the counter in module scope makes it shared across every instance of the
hook; keeping it in a plain local variable makes it reset every render. It has
to be a useRef — per instance, stable across renders. This is the kind of
detail that makes hand-rolling the pattern riskier than it looks.
The trade not taken
Do not hand-roll this.
TanStack Query, SWR and RTK Query all solve it structurally: state is keyed by the query key, so a response for a key nothing is subscribed to has nowhere to write. That is the generation check generalised, plus deduplication of identical in-flight requests, plus caching and retries you would otherwise also build. See TanStack Query and server state.
The reason to know the manual version is not that you will write it — it is
that the library only protects the requests that go through it. The moment
someone adds a plain fetch in a useEffect for “just this one endpoint”, the
race is back, and you need to recognise it in review.
The other genuine option is to move the problem server-side: one endpoint that takes the current state and returns everything the view needs means one request in flight and no ordering question. It costs flexibility and it eliminates an entire class of bug, which is the trade React Server Components is making.
Related
Interview angle 7
- “A search box shows results for the wrong query. What happened?” — two
requests were in flight and the responses arrived out of order, so the state
reflects the last response rather than the last request. Nothing
guarantees ordering across separate requests.
- Follow-up: “How do you fix it?” — guard the state update, not the network.
Take a generation number when the request starts and only write state if it
is still current.
AbortControlleron top of that cancels the wasted work.
- Follow-up: “How do you fix it?” — guard the state update, not the network.
Take a generation number when the request starts and only write state if it
is still current.
- “Is
AbortControllerenough on its own?” — no, and this is the part people miss. Abort is best-effort: if the response is already arriving, the abort can lose the race and the handler runs. Abort saves bandwidth; the generation check is what makes it correct.- Follow-up: “Is there a version without a counter?” — have the API echo the query it answered and compare on arrival. It survives remounts, needs no ref, and you can see it in the network tab.
- “Debounce or abort?” — both, plus the guard. Debounce reduces how many requests you send, abort cancels the one you superseded, and only the guard fixes the bug. Debounce alone still races whenever server latency varies more than the debounce window.
- “You abort a
POST. What happened on the server?” — possibly everything. Abort closes the client side; the server may have already committed. You are in the same position as a timeout — you do not know — so it needs an idempotency key to be safe to retry. - “Why do you see two requests in React dev mode?” — StrictMode double-invokes effects to surface missing cleanups, and it just surfaced yours. Abort in the cleanup rather than disabling StrictMode.
- “Your error dashboard is full of
AbortError.” — the cancel path is being reported as a failure. FilterAbortErrorin the catch, because otherwise someone mutes the alert and takes the real errors with it. - “Would you write this yourself?” — no, a query library keys state by the
request so a superseded response has nowhere to land. You learn the manual
pattern to recognise the bug in the one hand-rolled
fetchsomeone added outside the library.