Cancel old API calls — the take-latest pattern
When the same endpoint is hit repeatedly (search-as-you-type, paging), responses can land out of order. The fix is take-latest: abort the previous request with AbortController, or guard with a request-id ref so only the newest response updates state.
Network latency is non-deterministic. If you fire q=re, q=react, q=react\u00a0native in quick succession, the response for q=re may arrive last and overwrite the correct results. The fix is take-latest: ensure only the response for the most recent request can update state.
Approach 1 — AbortController (preferred).
useEffect(() => {
const ctrl = new AbortController();
fetch(`/search?q=${query}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setResults)
.catch(e => { if (e.name !== "AbortError") throw e; });
return () => ctrl.abort();
}, [query]);Cleanup runs before the next effect, so the previous request is aborted before the new one starts. The browser cancels the underlying network call — saves bandwidth and server work, not just client logic. Always swallow AbortError; it's not a real error.
Approach 2 — request-id ref (when you can't abort).
const reqIdRef = useRef(0);
async function search(q: string) {
const id = ++reqIdRef.current;
const res = await fetch(`/search?q=${q}`).then(r => r.json());
if (id !== reqIdRef.current) return; // stale — newer request is in flight
setResults(res);
}Use this when the API can't be aborted (e.g., GraphQL client without cancellation, or a Promise-based SDK).
The senior detail: aborting doesn't stop server-side work. AbortController closes the TCP connection but the server may still execute the query. For expensive endpoints, idempotency keys + server-side debounce matter. Frontend take-latest is a UX fix, not a backend protection.
Combine with debounce. Debounce reduces the number of requests; take-latest handles the ones that still race. They're complementary, not substitutes — debounce alone doesn't save you if one slow request lands after a fast one.
RTK Query / TanStack Query do this for you. Both use the query key as identity; switching keys cancels (or invalidates) the previous. If you're hand-rolling fetches in 2026, prefer one of these — AbortController + ref bookkeeping is exactly what they internalize.
Follow-up questions
- •Why is take-latest insufficient on its own without debounce?
- •How does TanStack Query implement request deduplication and cancellation?
- •When would you prefer take-every (run all, merge results) over take-latest?
- •What server-side patterns complement client-side cancellation?
Common mistakes
- •Treating AbortError as a real error and surfacing it to users.
- •Not aborting in useEffect cleanup, so stale responses still update state.
- •Assuming abort cancels server work — it only closes the connection.
- •Using stale-closure values inside the request-id check.
Performance considerations
- •Aborting saves the JSON parse + state update cost on the client.
- •Server may still spend CPU; pair with backend deduplication for hot endpoints.
Edge cases
- •Component unmount mid-flight — cleanup must still abort.
- •Same query string fired twice rapidly — second abort cancels first, network may dedupe.
- •Non-fetch APIs (axios, GraphQL) need their own cancellation primitives.
Real-world examples
- •Search autocomplete in any modern app, paginated tables, dependent dropdowns (country → city).