Frontend / APIs & data fetching / 05_abort_and_race_conditions.md

Race conditions and AbortController

Updated 7 interview angles 9 min read source
On this page10
  1. The mechanism: the network is not ordered
  2. Abort is the optimisation, not the fix
  3. Debounce, throttle, abort
  4. Beyond the typeahead
  5. What abort does not do
  6. Signals worth knowing
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

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.

text
  fire    A ("ab")  ────────────────────────────►
  fire    B ("abc")  ──────►

  arrive             B                A
                     │                │
                     └─ set(B)        └─ set(A)  <- wins

Nothing 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?”

tsx
// 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:

tsx
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

tsx
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:

tsx
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.

python
# 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 row

On 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

ts
// 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:

ts
.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

tsx
useEffect(() => { /* fetch */ }, [{ q, page }]);  // new object every render

A 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.

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. AbortController on top of that cancels the wasted work.
  • “Is AbortController enough 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. Filter AbortError in 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 fetch someone added outside the library.