Fetch: Abort
A fetch returns a promise, and a promise has no cancel button. Once you kick one off, the language gives you no built-in way to say “stop, I changed my mind.” Yet you often need exactly that. A user types in a search box and the previous request is now stale. Someone navigates away before a slow page finishes loading. The request is still traveling over the network, still holding a connection, still going to fire a .then you no longer care about.
The answer is a small helper object called AbortController. It doesn’t cancel promises in general — nothing does — but it gives you a standard way to signal “abort,” and fetch is built to listen for that signal. The same mechanism works for your own async code too, so once you learn it here you can reuse it anywhere.
The AbortController object
You start by creating a controller:
let controller = new AbortController();
There’s almost nothing to it. A controller gives you exactly two things:
- a method
abort()that you call to trigger cancellation, and - a property
signalthat other code watches to find out cancellation happened.
Calling abort() does two observable things at once:
controller.signalfires an"abort"event, andcontroller.signal.abortedflips fromfalsetotrue.
So there are always two sides to the arrangement. One side does the cancelable work and watches signal. The other side decides when to pull the plug and calls abort(). They communicate only through the signal — they don’t need to know anything else about each other.
Here’s the whole cycle with no fetch involved yet, just to see the two sides talk:
let controller = new AbortController();
let signal = controller.signal;
// The worker side: it holds the signal and reacts when abort fires.
signal.addEventListener('abort', () => alert("abort!"));
// The canceller side, at any later point:
controller.abort(); // triggers "abort!"
// The event has fired and the flag is now set
alert(signal.aborted); // true
Strip away the naming and AbortController is a purpose-built event emitter: abort() dispatches one event and sets one boolean. You could hand-roll something equivalent with your own callback. The payoff is that this shape is standard, and fetch already speaks it.
Try the two sides yourself. The worker side has already subscribed to the "abort" event; press the button to be the canceller. Notice the event fires exactly once, and the flag stays true afterwards:
Using with fetch
To make a fetch cancelable, hand it the controller’s signal in the options object:
let controller = new AbortController();
fetch(url, {
signal: controller.signal
});
From here fetch watches that signal for you. It has registered its own "abort" listener internally, so the moment you cancel, it tears down the request.
When you want to stop the request, call:
controller.abort();
fetch receives the event through signal and aborts the underlying network request. There’s one consequence you must handle: the fetch promise doesn’t resolve, it rejects. The rejection is a DOMException whose name is "AbortError". So wrap the await in try..catch and distinguish an intentional abort from a real network failure.
A complete example that gives the server one second, then gives up:
// abort in 1 second
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
try {
let response = await fetch('/api/reports/heavy-export', {
signal: controller.signal
});
} catch(err) {
if (err.name == 'AbortError') { // handle abort()
alert("Aborted!");
} else {
throw err; // some other error, re-throw
}
}
The preview here has no network, so this demo stands in a slow loader for fetch: a promise that resolves after two seconds, but rejects with a real DOMException named "AbortError" if its signal fires first. Start the load, then hit cancel before it finishes and watch the catch distinguish the abort from a genuine failure:
The debounced search box is the classic use. On each keystroke you abort the previous request before starting a new one, so a slow early response can’t arrive late and overwrite fresher results:
let controller;
async function search(query) {
// cancel the previous request, if any is still running
if (controller) controller.abort();
controller = new AbortController();
try {
let response = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal
});
return await response.json();
} catch (err) {
if (err.name !== 'AbortError') throw err;
// aborted by a newer keystroke — ignore
}
}
AbortController is scalable
One controller can drive many operations. Every consumer you give the same signal to gets canceled together when you call abort() once. That makes it easy to fan out several requests and treat them as a single unit.
Here’s a sketch that fires many URLs in parallel, all wired to one controller:
let urls = [...]; // a list of urls to fetch in parallel
let controller = new AbortController();
// an array of fetch promises, all sharing the same signal
let fetchJobs = urls.map(url => fetch(url, {
signal: controller.signal
}));
let results = await Promise.all(fetchJobs);
// a single controller.abort() call from anywhere
// aborts every fetch in the list at once
Here that fan-out is live. Three independent tasks share one signal; each finishes on its own timer, but a single abort() pulls the plug on every task still running:
The reach isn’t limited to fetch. If you have your own asynchronous work — a timer, a WebSocket read, a computation you poll — you can hook it into the same controller. Just listen for the abort event inside your task and stop when it fires:
let urls = [...];
let controller = new AbortController();
let ourJob = new Promise((resolve, reject) => { // our custom task
...
controller.signal.addEventListener('abort', reject);
});
let fetchJobs = urls.map(url => fetch(url, { // the fetches
signal: controller.signal
}));
// wait for the fetches and our task together
let results = await Promise.all([...fetchJobs, ourJob]);
// controller.abort() from anywhere now stops
// every fetch AND ourJob in one shot
Summary
AbortControlleris a tiny object with one method,abort(), and one property,signal. Callingabort()fires an"abort"event on the signal and setssignal.abortedtotrue.fetchintegrates with it directly. Passsignalin the options, and a latercontroller.abort()cancels the request. The fetch promise then rejects with anAbortError, which you handle intry..catchwhile re-throwing anything that isn’t an abort.- The pattern scales: share one signal across many fetches (and your own tasks) to cancel them all with a single call.
- The whole “call
abort()” → “react to theabortevent” contract is standard and general. You can lean on it far beyondfetch.