AbortController: Canceling Fetch Requests in JS
AbortController lets JavaScript cancel an in-flight fetch or async task on demand, preventing stale responses from overwriting newer state.
AbortController is a built-in browser and Node.js API that lets you cancel an in-flight fetch request, or any other async operation that supports it, on demand. It solves a problem that shows up the moment an app fetches data in response to something that can happen again before the first request finishes: search-as-you-type, tab switching, rapid pagination, or a component unmounting mid-request.
The problem it solves
Without cancellation, a fast-typing user triggers a fetch on every keystroke, and there’s no guarantee those requests resolve in the order they were sent. If a request for “ca” resolves after a request for “cat” — because the network happened to be slower for the second, shorter query — the UI briefly shows results for “ca” after the user has already typed “cat”. This is a race condition in the same family covered in what is a race condition: the fix isn’t to make requests faster, it’s to make stale ones stop mattering.
Basic usage
An AbortController exposes a signal property that you pass to fetch, and an abort() method that cancels any operation currently listening to that signal:
const controller = new AbortController();
fetch("/api/search?q=cat", { signal: controller.signal })
.then((res) => res.json())
.then((data) => renderResults(data))
.catch((err) => {
if (err.name === "AbortError") return; // expected, not a real failure
console.error(err);
});
// Later, to cancel:
controller.abort();
Calling abort() rejects the pending fetch promise with an AbortError, which is why the catch block checks for that name specifically and treats it as expected rather than logging it as a genuine failure.
The search-as-you-type pattern
The most common use combines AbortController with each new request superseding the last one:
let controller;
async function search(query) {
controller?.abort(); // cancel the previous in-flight request
controller = new AbortController();
const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
return res.json();
}
Each call to search cancels whatever request came before it, so only the most recent keystroke’s request can ever resolve successfully. This is often paired with debouncing to avoid firing a request on every keystroke in the first place — debounce delays when a request starts, AbortController handles what happens to the ones that are already in flight when a newer one arrives.
Cleaning up in component lifecycles
Frameworks that mount and unmount components need a way to cancel outstanding requests when a component goes away, so a slow response doesn’t try to update state that no longer exists. The typical pattern instantiates the controller when an effect runs and aborts it in the effect’s cleanup function:
useEffect(() => {
const controller = new AbortController();
fetch("/api/profile", { signal: controller.signal })
.then((res) => res.json())
.then(setProfile)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, []);
If the component unmounts before the fetch resolves, the cleanup function aborts it, and the .catch swallows the resulting AbortError instead of trying to call setProfile on an unmounted component.
Timeouts and combining signals
Two convenience features build on the same primitive. AbortSignal.timeout(ms) returns a signal that aborts itself automatically after a delay — useful for giving any fetch a hard timeout without manually wiring up a setTimeout:
fetch("/api/slow-endpoint", { signal: AbortSignal.timeout(5000) });
AbortSignal.any([signalA, signalB]) combines multiple signals into one that aborts as soon as any of its inputs does — handy when a request should cancel on either a component unmount or a timeout, whichever comes first.
Checking abort state and the reason
A signal exposes signal.aborted, a boolean you can check synchronously before starting expensive work — useful in a long-running function that receives a signal and wants to bail out early between steps, not just at the initial fetch call. It also exposes signal.reason, which holds whatever value was passed to abort() (or a default AbortError if none was given), so code further down the chain can distinguish why an operation was cancelled — a user-triggered cancellation versus a timeout — instead of treating every abort identically.
Beyond fetch
AbortSignal isn’t fetch-specific. Many async browser APIs — adding event listeners, certain stream operations, some third-party libraries’ async functions — accept a signal option following the same convention, so the same cancellation token can coordinate cleanup across several unrelated operations at once instead of tracking each one separately.
How this fits with async JavaScript generally
AbortController doesn’t change how promises or async/await work under the hood (see async/await vs. promises and the JavaScript event loop for that foundation) — it just gives you a standard way to short-circuit an operation that’s already pending. The promise still resolves or rejects through the normal event loop; abort() just forces it toward rejection early. This also pairs naturally with optimistic UI updates: if an optimistic update needs to roll back, aborting the underlying request that would have confirmed it is often part of that rollback path.
The takeaway
AbortController gives JavaScript a standard way to cancel a fetch or other async operation before it resolves, which matters whenever a newer request can make an older one’s result irrelevant. Wire a fresh controller’s signal into each fetch call, call abort() on the previous controller when a new request supersedes it, and treat the resulting AbortError as an expected outcome rather than a bug. Combined with debouncing and effect cleanup, it’s the standard defense against stale responses overwriting current UI state.
Keep reading
Takina · · 4 min read requestIdleCallback Explained
requestIdleCallback runs low-priority JavaScript when the browser is idle, without blocking rendering, input, or the main thread.
Takina · · 4 min read Dynamic import() in JavaScript: Code-Splitting Explained
JavaScript's dynamic import() loads a module on demand and returns a promise, letting you split bundles and defer code until it's actually needed.
Takina · · 5 min read Finding and Fixing Memory Leaks in JavaScript
A JavaScript memory leak happens when a reference outlives its usefulness and the garbage collector can't reclaim it. Common causes and how to find them.