Javascript
Ghost Updates and Race Conditions
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.
Showing: Fixed — AbortController
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 opssignal.reason— the value passed toabort(reason); useful for differentiating user vs timeout abortssignal.addEventListener('abort', fn)— react to cancellation in non-async codeAbortSignal.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
signalto fetch, callabort()in the cleanup function.IgnoreAbortErrorin 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.abortedin 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.
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