frontend / apis data fetching / 05_abort_and_race_conditions.md

AbortController, Request Dedup, Race Conditions

5 min read source

AbortController, Request Dedup, Race Conditions

TL;DR

The classic frontend bug: user types “ab” → request A fires; user types “abc” → request B fires; A’s response arrives after B’s, the UI now shows results for “ab.” That’s a race condition caused by treating responses as ordered. Fix it with AbortController (cancel A when B starts) and/or by ignoring late responses with a generation counter. TanStack Query handles most of this for you, but every senior should be able to explain — and fix — the bug without a library.

Interview Q&A

Q: What is AbortController?

A: A standard browser API that lets you cancel any operation accepting an AbortSignal — most importantly fetch. When you call controller.abort(), the in-flight request rejects with an AbortError.

const controller = new AbortController();
fetch("/api/search?q=ab", { signal: controller.signal })
  .then(r => r.json())
  .catch((e) => {
    if (e.name === "AbortError") return;   // ignore, we cancelled
    throw e;
  });

controller.abort();   // cancels the fetch

AbortSignal also works with addEventListener, setTimeout (via AbortSignal.timeout()), ReadableStream, and other modern APIs.

Q: The classic typeahead race — show the broken and fixed versions.

A:

// BROKEN — last response wins, not last request
function useSearch(q: string) {
  const [results, setResults] = useState<Item[]>([]);
  useEffect(() => {
    fetch(`/api/search?q=${q}`).then(r => r.json()).then(setResults);
  }, [q]);
  return results;
}

User types fast: A (“ab”), B (“abc”), C (“abcd”). If A is slow, A’s response arrives last and overwrites C’s results.

// FIXED — abort the previous request on each new one
function useSearch(q: string) {
  const [results, setResults] = useState<Item[]>([]);

  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();   // cleanup runs before next effect
  }, [q]);

  return results;
}

The useEffect cleanup runs before the next effect when q changes — so request A is aborted the moment B starts. Plus on unmount the final pending request is aborted too.

Q: What if AbortController isn’t enough — the server already responded?

A: AbortController cancels the network request and the in-flight fetch promise, but if A’s response is already on the wire when B starts, A may still resolve. Belt-and-braces approach: a generation counter that ignores late responses:

function useSearch(q: string) {
  const [results, setResults] = useState<Item[]>([]);
  const genRef = useRef(0);

  useEffect(() => {
    const myGen = ++genRef.current;
    const controller = new AbortController();
    fetch(`/api/search?q=${q}`, { signal: controller.signal })
      .then(r => r.json())
      .then((data) => {
        if (myGen === genRef.current) setResults(data);   // ignore stale
      })
      .catch(() => {});
    return () => controller.abort();
  }, [q]);

  return results;
}

AbortController for the network, generation counter for the state update. TanStack Query does both internally — keyed by queryKey, late responses for a key whose subscriber moved on are dropped.

Q: Debounce vs throttle vs abort — when each?

A:

Effect Use for
Debounce wait N ms of quiet, then fire typeahead — don’t ping per keystroke
Throttle fire at most once per N ms scroll/resize handlers
Abort fire immediately; cancel the previous in-flight typeahead with fast responses, or for cancelling on navigation

Production typeahead combines debounce 200ms (skip in-progress typing) + abort (cancel the previous if the user keeps typing). Debounce reduces request count; abort handles late arrivals.

Q: How do you abort on route change in Next/React Router?

A: Tie the controller to a useEffect keyed on the route; the cleanup aborts the request when the user navigates.

const params = useParams();
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/items/${params.id}`, { signal: controller.signal })
    .then(...);
  return () => controller.abort();
}, [params.id]);

Q: How does request dedup work in TanStack Query?

A: If two components mount with the same queryKey at the same time, TanStack Query fires one fetch and shares the result. Internally it tracks in-flight promises per key — subsequent subscribers join the same promise. This is dedup across components, not across keys (you still need cursors/keys to be deterministic — see 02_tanstack_query.md).

Q: What’s AbortSignal.timeout(ms)?

A: A built-in signal that aborts after a timeout — no manual setTimeout + controller.abort() plumbing.

const res = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });

AbortSignal.any([sig1, sig2]) combines multiple signals — useful when you want to abort on either timeout or user cancel.

Q: Aborting a fetch doesn’t abort the server’s work — does it matter?

A: For browser perf and UX, no — the client doesn’t care once it’s cancelled. For server load, sometimes yes — if you’re paying for the request, the server keeps doing the work. Long-running endpoints can listen for the request.signal.aborted event on the server side (Node/Edge runtimes) and short-circuit. Mostly a backend concern, but a senior should know the connection.

Gotchas / edge cases

  • Forgetting the AbortError catch — your error reporter logs every cancellation as a real error. Always filter.
  • Aborting after fetch resolved but before .json().json() also respects the signal (since it reads the body stream); cancellation can throw inside the JSON parse. Catch it the same way.
  • Multiple effects sharing one controller — don’t. One controller per effect (or per logical request).
  • React 18 StrictMode double-invokes effects in dev — the first invocation’s cleanup will abort the first fetch, the second invocation re-fetches. This is intentional; it surfaces missing cleanups. Don’t disable StrictMode to “fix” it — fix the missing cleanup.
  • Memoised fetch results — caching at the fetch layer + abort doesn’t mix well; cancel-then-cache is awkward. Cache at a higher layer (TanStack Query).
  • useEffect cleanup runs on dep change, not before — actually it runs both ways: cleanup of the previous run executes before the next run’s effect body. That ordering is what makes the abort pattern work.

What a senior is expected to say

  • “Last-response-wins is the bug. Abort the previous request when a new one starts, and use a generation counter to ignore late responses that escaped the abort.”
  • “Debounce + abort combined — debounce reduces fire-rate, abort handles the in-flight you couldn’t cancel before.”
  • AbortController works for fetch, addEventListener, modern stream APIs — anywhere that accepts AbortSignal. I use AbortSignal.timeout instead of manual setTimeout plumbing.”
  • “TanStack Query handles this for me by key, but I know the underlying pattern in case I’m working without a library.”

Cross-references

Further reading