HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 672 min read

Request Cancellation & Stale Responses (AbortController)

Web developmentweb development / frontend

The bug report

"Search shows the wrong results. Type 'react' fast and sometimes you get results for 'rea'. Refreshing fixes it."

The code looks fine:

useEffect(() => {
  fetch(`/api/search?q=${q}`)
    .then(r => r.json())
    .then(setResults);
}, [q]);

No error in the console. Works perfectly on localhost.

Reasoning through it

Typing "react" fires 5 requests. They are 5 independent network calls — nothing guarantees they come back in order.

q="rea"   sent at 0ms   → server takes 300ms → lands at 300ms
q="react" sent at 80ms  → server takes 120ms → lands at 200ms

Timeline of setResults: "react" at 200ms, then "rea" at 300ms. Last write wins, and the last write is the stale one. The UI now shows results for a query the user already replaced.

It never reproduces locally because localhost latency is ~2ms with almost zero variance. In production p50 is 80ms and p99 is 900ms — that spread is the whole bug.

The concept

This is a race condition on out-of-order responses. Debouncing (a covered topic) reduces how often it happens; it does NOT fix it. Two requests can still be in flight, and the slow one can still land last.

The fix is to make every response prove it's still wanted. Two ways:

  1. Cancel the old request — AbortController
useEffect(() => {
  const ac = new AbortController();
  fetch(`/api/search?q=${q}`, { signal: ac.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(e => { if (e.name !== 'AbortError') throw e; });
  return () => ac.abort();   // cleanup runs before the next effect
}, [q]);
  1. Ignore the old response — a stale flag
let cancelled = false;
...then(d => { if (!cancelled) setResults(d) });
return () => { cancelled = true };

The nuances people get wrong

  • Forgetting the catch. abort() rejects the promise with an AbortError. Unhandled, it shows up as noise in Sentry or an unhandled rejection warning.
  • Aborting does NOT reliably stop server work. The request may already be processed; the client just stops listening. Never rely on abort for correctness on the server — use idempotency there.
  • One controller per request, not per component. A controller is single-use: once aborted, that signal stays aborted forever. Create a fresh one each effect run.
  • signal is not fetch-only. It works with addEventListener({ signal }) for auto-removed listeners, and AbortSignal.timeout(5000) gives you a real fetch timeout — fetch has no timeout option:
fetch(url, { signal: AbortSignal.timeout(5000) })
  • Axios: pass the same { signal }. The old CancelToken API is deprecated.
  • React Query / SWR handle this for you — RQ passes a signal into your queryFn. Use it; don't hand-roll.

Try this

Add an artificial delay to your search endpoint: sleep(Math.random() * 800) before responding. Type a 6-letter query fast and watch the wrong results stick. Then add AbortController and repeat.

Question: your effect depends on [q] only, but your fetch URL also includes a filters object from props. What breaks, and how does the cleanup function behave when filters changes but q doesn't?