Skip to story

Javascript

Ghost Updates and Race Conditions

11 min read · May 31, 2026 ★ Flagship

Reading level

The flickering search results

User types "re" in a search box. A request fires. They keep typing: "rea". Another request fires. Then "reac", "react". Four requests are now in-flight.

The requests finish out of order. "react" returns first (fast cache hit), then "re" returns last (slow, no cache). The UI shows results for "re" — the query the user finished typing past 400ms ago. The results are wrong and stale.

This is a race condition. Multiple async operations compete to update the same state, and the wrong one wins.

The bug is classic and subtle: you're not reading incorrect data — you're reading correct data for a query the user abandoned. The fix isn't to add debouncing (though that helps reduce requests) — it's to cancel stale requests when a new one supersedes them.

Race conditions in UI data fetching are a consistency problem, not a performance problem. Even with perfect debouncing, slow network conditions or cache misses can cause out-of-order responses. The only correct fix is cancellation at the request level, not rate-limiting at the firing level.

AbortController: cancel what you started

AbortController is a browser API that lets you cancel a fetch request mid-flight. You create a controller, pass its signal to the fetch call, and call abort() when you want to cancel.

const controller = new AbortController();

fetch('/api/search?q=react', { signal: controller.signal })
  .then(r => r.json())
  .then(data => setResults(data))
  .catch(err => {
    if (err.name === 'AbortError') return; // expected — not a real error
    throw err;
  });

// Cancel the request (e.g. user typed more)
controller.abort();

When you call abort(), the fetch rejects with an AbortError. You catch it and ignore it — it's not a real error, it's an intentional cancellation.

The pattern in a React useEffect combines AbortController with the cleanup function: every new search creates a new controller; the cleanup aborts the previous one.

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(r => r.json())
    .then(data => setResults(data))
    .catch(err => { if (err.name !== 'AbortError') throw err; });

  return () => controller.abort(); // cancel on query change or unmount
}, [query]);

AbortController's signal propagates through the entire fetch chain including any intermediary services (e.g., fetch wrappers, TanStack Query's internal fetcher). Libraries built on the Fetch API honour the signal natively. For non-fetch async operations, check the signal manually:

async function longOperation(signal) {
  for (const item of largeList) {
    if (signal.aborted) return; // exit early if cancelled
    await processItem(item);
  }
}

Three ways ghost updates bite you

Problem 1: Search box flicker — results flash between old and new as requests resolve out of order.

Problem 2: Tab-switching stale data — user switches tabs quickly; the last-selected tab shows data for a previously-selected one.

Problem 3: Unmounted component setState — fetch resolves after navigation; React warns "Can't perform a React state update on an unmounted component."

// ❌ All three bugs live here
useEffect(() => {
  fetch(`/api/data?id=${id}`)
    .then(r => r.json())
    .then(data => setData(data)); // no cleanup, no abort
}, [id]);

A naïve "fix" is to track whether the component is still mounted:

// ⚠️ Better, but request is still in-flight — wasting bandwidth
useEffect(() => {
  let mounted = true;
  fetch(`/api/data?id=${id}`)
    .then(r => r.json())
    .then(data => { if (mounted) setData(data); });
  return () => { mounted = false; };
}, [id]);

This silences the React warning, but the network request continues. AbortController actually cancels it, freeing bandwidth and server resources.

The unmount-check pattern also has a correctness gap: if two requests fire and both resolve before unmount, the later-arriving response still wins. AbortController guarantees only the last-started request can ever call setData.

The complete cancellation pattern

// ✅ Full pattern: abort on re-run and unmount
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  async function load() {
    try {
      const res = await fetch(`/api/data?id=${id}`, { signal });
      const data = await res.json();
      setData(data);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err.message);
      // AbortError is expected — silently ignore
    }
  }

  load();
  return () => controller.abort(); // cleanup = cancel
}, [id]);

Every time id changes, React runs the cleanup of the old effect (aborting the old request) and starts the new effect (starting a fresh request). Only one request can ever complete.

If you're using TanStack Query or SWR, they handle this for you:

// TanStack Query — automatic cancellation via AbortSignal
const { data } = useQuery({
  queryKey: ['item', id],
  queryFn: ({ signal }) => fetch(`/api/data?id=${id}`, { signal }).then(r => r.json()),
});

TanStack Query passes the AbortSignal automatically to your query function. You just thread it through to the fetch call.

For non-hook contexts (vanilla JS, custom data layers), the same pattern applies with a controller stored in a ref or closure:

class SearchService {
  #controller = null;

  async search(query) {
    this.#controller?.abort(); // cancel previous
    this.#controller = new AbortController();
    const res = await fetch(`/api/search?q=${query}`, {
      signal: this.#controller.signal
    });
    return res.json();
  }
}

Pattern at a glance

Annotated example: fetch in useEffect — no abort vs AbortController

❌ NO ABORT

useEffect(() => {
  fetch(`/api/data?id=${id}`)
    .then(r => r.json())
    .then(data => setState(data));
  // no cleanup — ghost update on unmount
}, [id]);

No abort: stale response updates unmounted component

✅ ABORTCONTROLLER

useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/data?id=${id}`, {
    signal: ctrl.signal
  })
    .then(r => r.json())
    .then(data => setState(data))
    .catch(e => {
      if (e.name !== 'AbortError') throw e;
    });
  return () => ctrl.abort();
}, [id]);

AbortController: in-flight request cancelled on unmount

Try it: race condition vs abort

Click "Broken" and rapidly type different letters — watch the results flicker between them. Switch to "Fixed" and the same fast input always shows only the latest result.

The demo simulates variable network latency. The broken version resolves all requests; the fixed version aborts superseded ones. Check the request count in both modes.

Open the Network panel in DevTools. "Fixed" mode shows cancelled requests with a red strikethrough — proof the browser actually aborted the connection, not just ignored the response.

⚡ Interactive demo

AbortSignal beyond fetch

AbortController works with fetch — but also with any modern API that accepts a signal: addEventListener, setTimeout via AbortSignal.timeout(), and Web Animations API.

AbortSignal.timeout(ms) creates a signal that automatically aborts after a timeout — useful for fetch timeout without manual controller management:

// Auto-cancels after 5 seconds
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000)
});

AbortSignal.any([signal1, signal2]) (Chrome 116+) combines multiple signals — useful for "cancel on timeout OR on user action":

const userController = new AbortController();
const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(10_000)
]);
await fetch('/api/upload', { signal });

Full signal API surface for production use:

  • signal.aborted — boolean; poll in loops or check before expensive ops
  • signal.reason — the value passed to abort(reason); useful for differentiating user vs timeout aborts
  • signal.addEventListener('abort', fn) — react to cancellation in non-async code
  • AbortSignal.any() — compose multiple abort conditions

In data fetching libraries: TanStack Query v5 passes signal automatically; SWR passes it via fetcher(key, { signal }); Apollo Client has its own cancellation model via observables.

References

Remember

Key takeaways

  • Race conditions happen when multiple requests can all update the same state — the last one to arrive wins, even if it's not the latest data.
    Debouncing reduces requests but doesn't prevent out-of-order responses under real network conditions — cancellation is the correct fix.
    The unmount-check pattern silences warnings but wastes bandwidth; AbortController actually cancels the network request.
  • Create one AbortController per effect, pass signal to fetch, call abort() in the cleanup function.
    Ignore AbortError in catch — it's intentional cancellation, not a real error condition.
    AbortSignal.timeout(ms) and AbortSignal.any() compose cancellation conditions cleanly without manual cleanup logic.
  • TanStack Query and SWR handle this for you — if you're using them, pass the signal they provide to your fetch call.
    Check signal.aborted in long-running non-fetch async operations (loops, WASM calls) to exit early on cancellation.
    In DevTools Network panel, aborted requests show as cancelled — visible proof the browser dropped the connection, not just ignored the response.

Enjoyed this case?

Case 1 of 3 in Javascript · 13 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.