Aborting Requests & Timeouts
Async operations often outlive the reason you started them: a user navigates away mid-request, types a new search query, or a slow server keeps you waiting forever. AbortController is the standard, promise-friendly way to cancel in-flight work — most notably fetch, but also addEventListener, timers, and any custom async code you write. It ships in modern browsers and Node.js (16+), so the same pattern works everywhere.
How AbortController and AbortSignal work
An AbortController is a small object with two parts: a signal you hand to the operation you want to make cancellable, and an abort() method you call to cancel it. The signal is an AbortSignal — an event target that fires an "abort" event and flips its aborted property to true the moment cancellation happens.
You create one controller per cancellable task, pass its signal along, and keep the controller around so you can call abort() later.
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener("abort", () => {
console.log("Aborted! Reason:", signal.reason);
});
controller.abort("user cancelled");
console.log("aborted?", signal.aborted);
Output:
Aborted! Reason: user cancelled
aborted? true
The optional argument to abort() becomes signal.reason. If you omit it, the reason defaults to an AbortError DOMException.
Aborting a fetch
fetch accepts a signal in its options object. When the signal aborts, the network request is torn down and the returned promise rejects with an AbortError. Because that rejection looks like any other failure, you should detect aborts explicitly so you don’t show a scary error for an intentional cancel.
async function loadUser(id, signal) {
try {
const res = await fetch(`/api/users/${id}`, { signal });
return await res.json();
} catch (err) {
if (err.name === "AbortError") {
console.log("Request was cancelled — ignoring.");
return null;
}
throw err; // a real error
}
}
const controller = new AbortController();
loadUser(42, controller.signal);
// Changed our mind before it finished:
controller.abort();
A classic use case is a type-ahead search: every keystroke starts a new request and aborts the previous one so only the latest result wins.
let active = null;
async function search(query) {
active?.abort(); // cancel the previous in-flight search
active = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: active.signal,
});
return await res.json();
} catch (err) {
if (err.name !== "AbortError") throw err;
}
}
Adding a timeout
There are two ways to give an operation a deadline. The simplest is the built-in AbortSignal.timeout(ms), which returns a signal that auto-aborts after the given milliseconds with a TimeoutError.
async function fetchWithTimeout(url, ms = 5000) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
return await res.json();
} catch (err) {
if (err.name === "TimeoutError") {
throw new Error(`Request to ${url} timed out after ${ms}ms`);
}
throw err;
}
}
If you need both a manual cancel and a timeout, combine signals with AbortSignal.any([...]). The resulting signal aborts as soon as any of its inputs does.
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal, // manual cancel
AbortSignal.timeout(8000), // hard deadline
]);
fetch("/api/report", { signal });
// controller.abort() OR the 8s timeout will cancel it.
| API | Aborts when | Reason / error name | Reusable |
|---|---|---|---|
controller.abort(reason) | you call it | your reason, else AbortError | one-shot |
AbortSignal.timeout(ms) | ms elapses | TimeoutError | one-shot |
AbortSignal.any([...]) | any input aborts | propagated from the winner | derived |
AbortSignal.abort(reason) | immediately | already aborted | static helper |
Tip: A timed-out request still consumed time and possibly server resources. Pair short client timeouts with idempotent or retry-safe endpoints so a retry after a timeout can’t cause duplicate side effects.
Cleanup on teardown
When a component unmounts or a scope ends, abort any work it started so callbacks don’t fire against gone-away state and listeners don’t leak. The signal also doubles as a one-shot way to remove event listeners: pass it as the third argument to addEventListener, and the listener is removed automatically when the signal aborts.
function mountWidget(el) {
const controller = new AbortController();
const { signal } = controller;
// Listener is auto-removed on abort — no manual removeEventListener.
el.addEventListener("click", () => console.log("clicked"), { signal });
fetch("/api/widget-data", { signal })
.then((r) => r.json())
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
// Return a teardown function (e.g. React useEffect cleanup).
return () => controller.abort();
}
In React this maps cleanly onto an effect:
useEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort(); // runs on unmount / dep change
}, [id]);
Warning: Always check
signal.aborted(or guard theAbortError) before updating state in async callbacks. Aborting cancels the request, but a.thenthat already started running will still execute.
Best Practices
- Pass
signalthrough to every cancellable call so cancellation propagates end to end. - Treat
AbortError/TimeoutErroras expected outcomes, not bugs — branch onerr.nameand swallow intentional aborts. - Use
AbortSignal.timeout(ms)for deadlines andAbortSignal.any([...])to merge a timeout with a manual cancel. - Create a fresh controller per operation; signals are one-shot and cannot be reset after aborting.
- Use
{ signal }onaddEventListenerto auto-remove listeners and avoid leaks. - Return
() => controller.abort()as your teardown so navigation or unmount cancels in-flight work.