Networking
fetch + AbortController: Cancel Ghost Requests
Reading level
The request you can't stop
User navigates to a product page. A large product details fetch starts. They immediately click away to another page. The original fetch is still running — it will complete, call your state setter, and maybe crash the app ("can't update state on unmounted component"). You need to cancel it. AbortController is how.
Every fetch request you start should have a cancellation strategy. The two common triggers: (1) the component unmounted before the response arrived, or (2) a new request superseded the old one. Without cancellation, both result in stale-data updates, wasted bandwidth, and potential state corruption.
This case is the networking companion to the abort-controller-ghost-updates case — focused on the raw fetch pattern without React context. The same AbortController API, applied in vanilla JS, custom hooks, and service layers.
The three-line cancellation pattern
// 1. Create a controller
const controller = new AbortController();
// 2. Pass the signal to fetch
const res = await fetch('/api/data', { signal: controller.signal });
// 3. Cancel when needed
controller.abort(); // network connection dropped immediately
When abort() is called, the fetch rejects with a DOMException named AbortError. Catch it and return — it's not a real error.
// Complete fetch wrapper with cancellation
async function fetchWithAbort(url, signal) {
try {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null; // cancelled — not an error
throw err; // real error — re-throw
}
}
// Usage: cancel previous, start new
let currentController = null;
async function loadData(id) {
currentController?.abort();
currentController = new AbortController();
const data = await fetchWithAbort(`/api/${id}`, currentController.signal);
if (data) updateUI(data); // data is null if aborted
}
For parallel requests with a single cancellation point:
const controller = new AbortController();
const { signal } = controller;
// All three requests share one signal — one abort() cancels all
const [user, posts, comments] = await Promise.all([
fetch('/api/user', { signal }).then(r => r.json()),
fetch('/api/posts', { signal }).then(r => r.json()),
fetch('/api/comments', { signal }).then(r => r.json()),
]);
The search that showed the wrong results
David is searching for "react hooks" in a developer docs search box. He types quickly — "r", "re", "rea", "reac", "react", "react ", "react h", "react ho", "react hoo", "react hook", "react hooks". Each keystroke fires a fetch to the search API. Then something strange happens: the results flash to "react" results momentarily, then settle on "react hooks." He types again — same thing. The results briefly show something wrong before correcting. It's disorienting and makes the search feel broken.
What happened technically: each keystroke fired a fetch with no cancellation of the previous request. The network isn't orderly — the response for "react" (a shorter, more cached query) arrived 200ms after the response for "react hooks." JavaScript ran the state update for "react" last, overwriting the "react hooks" results. This is a race condition: two async operations competing to write the same state, where "last to resolve" wins regardless of which request was sent most recently.
This pattern causes invisible data corruption in production. Users rarely report "stale results" explicitly — they just think search is unreliable. It's especially common on: typeahead search, paginated tables with rapid sort/filter changes, and tab-switching that triggers data loads. On slower connections or high-latency APIs, the race window is longer and the bug manifests more frequently. Monitoring won't catch it — the state update succeeds, just with wrong data.
Abort the old request before starting the new one
The fix: every time a new search query fires, cancel the previous fetch before starting the new one. AbortController does this — you call abort() on the previous controller, and the browser drops the network connection. The aborted fetch rejects with an AbortError, which you catch and ignore (it's not a real error). Only the most recent fetch can update results.
The team replaced the raw fetch with a controlled pattern:
// Module-level controller reference
let searchController = null;
async function search(query) {
// Cancel the previous request if still in flight
searchController?.abort();
searchController = new AbortController();
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: searchController.signal,
});
const data = await res.json();
renderResults(data); // only reaches here if not aborted
} catch (err) {
if (err.name === 'AbortError') return; // expected — ignore
showError(err); // real network error
}
}
The race condition was eliminated. Results now always match the most recent query. Open the Network panel — aborted requests show as "cancelled" with a strikethrough, confirming the connection was truly dropped.
They also added debounce (300ms) on top of the abort pattern — debounce reduces how many requests fire, abort handles the ones that slip through during fast typing. These are complementary tools, not alternatives: debounce reduces API cost, abort guarantees data integrity. Post-fix, they migrated to TanStack Query which handles both automatically via its built-in request cancellation and deduplication. The search component dropped from 80 lines to 20.
Pattern at a glance
Annotated example: overlapping fetches vs AbortController cancellation
❌ RACE CONDITION — LAST RESOLVES WINS
// Naive — no cancellation
input.addEventListener('input', async (e) => {
const res = await fetch(
`/api/search?q=${e.target.value}`
);
const data = await res.json();
// BUG: whichever response arrives last
// updates the UI — not the latest query
renderResults(data);
});
// Timeline:
// t=0ms fetch("react h") started
// t=10ms fetch("react ho") started
// t=200ms fetch("react ho") resolves ✓
// t=300ms fetch("react h") resolves — OVERWRITES!
Stale response overwrites fresh results
✅ ABORTCONTROLLER — ONLY LATEST WINS
let controller = null;
input.addEventListener('input', async (e) => {
// Cancel the previous in-flight request
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(
`/api/search?q=${e.target.value}`,
{ signal: controller.signal }
);
const data = await res.json();
renderResults(data); // only latest reaches here
} catch (err) {
if (err.name === 'AbortError') return; // expected
showError(err);
}
});
// Old requests cancelled in Network panel
Previous fetch aborted — only latest query renders
Try it: uncancelled vs cancelled fetch
Start a slow fetch then immediately click Cancel. "Broken" — the fetch completes and updates state anyway. "Fixed" — the fetch is aborted, no state update, no error.
Open the Network panel. "Fixed" shows the request as "cancelled" with a strikethrough — the connection was actually dropped, not just ignored.
Compare request duration in Network panel: cancelled requests in fixed mode terminate immediately. Broken mode requests run to completion, wasting bandwidth and triggering unnecessary state updates.
Showing: Fixed — AbortController
AbortController patterns for every context
The three-line pattern works anywhere:
// 1. Create controller
const controller = new AbortController();
// 2. Pass signal to fetch
const res = await fetch(url, { signal: controller.signal });
// 3. Cancel when needed (new request, component unmount, etc.)
controller.abort();
Key pitfall: AbortError is not a real error — always check err.name === 'AbortError' and return early. Re-throwing it will trigger your global error handler unnecessarily.
React useEffect cleanup is the canonical place to call abort():
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
React.useEffect(() => {
const controller = new AbortController();
async function fetchResults() {
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setResults(await res.json());
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
}
fetchResults();
// Cleanup: runs on next render (new query) AND on unmount
return () => controller.abort();
}, [query]); // re-runs when query changes
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
Implementation notes:
- Debounce vs abort: debounce reduces how often fetch fires (fewer requests); abort ensures stale requests can't corrupt state (data integrity). Use both
- AbortSignal.timeout(ms): creates a signal that auto-aborts after N ms — no controller needed for simple fetch timeouts
- One signal, many fetches: pass the same signal to multiple
fetch()calls in aPromise.all— oneabort()cancels all of them - TanStack Query: passes
AbortSignalto query functions automatically in v5 — thread it tofetch()inside your query function
At library/service layer scale, encode abort support in your fetch wrapper:
// Service layer with built-in cancellation
class ApiService {
#controllers = new Map();
async get(key, url, options = {}) {
this.cancel(key); // cancel any pending request with same key
const controller = new AbortController();
this.#controllers.set(key, controller);
try {
const res = await fetch(url, {
...options,
signal: controller.signal,
});
this.#controllers.delete(key);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null;
throw err;
}
}
cancel(key) {
this.#controllers.get(key)?.abort();
this.#controllers.delete(key);
}
}
This pattern gives you named cancellation — cancel all "search" requests without affecting "user-profile" requests. Useful in tab-based UIs or wizard flows where navigating away should cancel only the relevant data loads. React Query and SWR provide this at the hook level; the service pattern is for non-React contexts or custom fetch layers below the framework.
References
Remember
Key takeaways
-
Every fetch that can be superseded or outlive its context needs an AbortController. Create controller, pass signal, call abort() on cleanup.AbortError is not a real error — catch it and return null. Only re-throw non-abort errors to your error handler.One controller can cancel many parallel fetches — pass the same signal to all fetch calls in a Promise.all to abort the whole batch at once.
-
AbortSignal.timeout(ms) creates an auto-cancelling signal — no manual controller needed for simple fetch timeouts.In React useEffect, the cleanup function (return () => ...) is where abort() belongs — it runs on component unmount and on every effect re-run.TanStack Query v5 passes AbortSignal automatically to query functions — thread it to fetch. SWR does the same. Using these libraries correctly is using AbortController correctly.
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