How do you cancel stale API calls when a user clicks a button multiple times?
Two solid approaches: (1) AbortController — store the controller in a ref, abort it before each new request, pass signal to fetch; (2) request-id counter — bump a ref on each call, ignore responses whose id != current. Approach 1 actually cancels the network call (server stops sending); approach 2 just discards the result. Best combined: abort + id-check guards against late stragglers. React Query / SWR handle this automatically.
Classic race condition: user clicks repeatedly, requests fire in order, but responses can arrive out of order — and the last render may show an earlier response.
The bug
function Search({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/search?q=${query}`).then(r => r.json()).then(setResults);
}, [query]);
return <Results items={results} />;
}User types "react" fast. Three requests fire. Backend is slower for "re" than "react". Order of resolution:
- "react" responds → setResults shows React results ✓
- "re" responds → setResults overwrites with stale "re" results ✗
Approach 1: AbortController
Cancel the previous request before starting a new one.
function Search({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/search?q=${query}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setResults)
.catch(err => { if (err.name !== 'AbortError') throw err; });
return () => ctrl.abort(); // cleanup runs before next effect → cancels in-flight
}, [query]);
return <Results items={results} />;
}The effect cleanup runs before the next effect, so when query changes, the previous fetch is aborted. Browser tears down the connection (or stream), and the catch swallows the AbortError.
Approach 2: request-id (no abort, just discard)
function Search({ query }) {
const [results, setResults] = useState([]);
const reqIdRef = useRef(0);
useEffect(() => {
const id = ++reqIdRef.current;
fetch(`/search?q=${query}`)
.then(r => r.json())
.then(data => {
if (id !== reqIdRef.current) return; // outdated → ignore
setResults(data);
});
}, [query]);
return <Results items={results} />;
}Simpler, works without AbortController, but the network request still completes (server-side cost) and you still parse the body.
Best of both (event handler version)
For a button click (not bound to effect deps), keep a controller ref:
function FetchButton() {
const ctrlRef = useRef(null);
const reqIdRef = useRef(0);
const [data, setData] = useState(null);
async function onClick() {
ctrlRef.current?.abort();
const ctrl = new AbortController();
ctrlRef.current = ctrl;
const id = ++reqIdRef.current;
try {
const res = await fetch('/api/data', { signal: ctrl.signal });
const body = await res.json();
if (id !== reqIdRef.current) return;
setData(body);
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}
return <button onClick={onClick}>Fetch</button>;
}Abort cancels in-flight; id check guards against stragglers that already passed the abort check.
Bonus: debounce the input
If the trigger is rapid typing (search-as-you-type), also debounce so you don't fire a request for every keystroke:
const debounced = useDeferredValue(query); // or use-debounce
useEffect(() => { ... }, [debounced]);Debounce reduces the number of requests. Cancel/id-check protects against the ones that do fire racing.
Or: just use React Query
const { data } = useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) => fetch(`/search?q=${query}`, { signal }).then(r => r.json()),
});React Query automatically aborts the previous query when the key changes, dedupes concurrent identical queries, caches results, and discards stale responses. The hand-rolled pattern above is what it does internally.
Follow-up questions
- •When does AbortError fire on the catch — and how do you distinguish it from real errors?
- •What happens server-side when the client aborts?
- •How does React Query handle this same race condition?
- •When would the request-id pattern be enough without abort?
Common mistakes
- •No cancel at all — last-arriving response wins, even if it's the oldest.
- •Catching AbortError as a real error and surfacing a 'failed' UI when in fact it was intentional cancel.
- •Calling abort on every render unconditionally (without storing the controller correctly) — cancels the request that just started.
- •Forgetting that body parsing (res.json) is itself async — abort during parse may leave you with a half-read body.
- •Aborting on debounce-fire and then debounce immediately re-fires — wasteful churn; debounce first, then cancel only on real key change.
Performance considerations
- •Aborting saves bandwidth (server may stop sending), browser parse cost (no JSON parse on cancelled body), and React re-render cost (no stale setState). For search-as-you-type at scale, abort+debounce combined cuts effective request rate by 80%+.
Edge cases
- •Strict Mode in dev double-invokes effects, so you'll see abort fire twice on mount — expected, harmless.
- •If the response is non-cancellable (already read into JS), the cleanup just runs after-the-fact; id-check protects you.
- •On mutations (POST/PUT/DELETE), 'cancelling' is dangerous — the server may have already committed. Either prevent re-click via disabled button, or use idempotency keys.
- •WebSocket subscriptions need their own version of this: id messages and ignore stale ones.
Real-world examples
- •GitHub's omnisearch debounces input and cancels stale requests.
- •Algolia / Typesense client libraries handle this internally.
- •React Query, SWR, TanStack Query — all abort previous on key change.