JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Cancelling async work with AbortController
Cancel in-flight async work with AbortController, and build your own abortable promises that reject with AbortError instead of leaking.
What you will learn
- Pass controller.signal into an operation and call abort() from the caller
- Make a promise abortable: check aborted, listen for 'abort', release the resource
- Separate cancellation from failure by checking err.name, not err.message
- Create a fresh controller per attempt; an aborted signal never resets
Understanding Cancelling async work with AbortController
A promise is a handle on a result that is already in motion, which is why it has no cancel method: the consumer of a value has no business steering the producer. AbortController splits that job into two objects. The controller has one method, abort(), and belongs to whoever started the work, while controller.signal is a read-only view of the same state and is what you hand to the work itself. Calling abort() sets signal.aborted to true, stores a reason in signal.reason, and dispatches a single 'abort' event on the signal; that is the entire mechanism.
Nothing actually stops unless the receiving code looks at the signal. An abortable operation therefore inspects it at three points: before starting, so an already-aborted signal rejects immediately; while waiting, by listening for the 'abort' event, releasing the timer or socket, and rejecting with signal.reason; and between awaits in a multi-step routine, with signal.throwIfAborted(). fetch and addEventListener already implement this behind a { signal } option, but a promise you construct yourself gets none of it for free.
Cancellation arrives as a rejection, so you have to tell it apart from real failure. The default reason is a DOMException whose name is 'AbortError', while AbortSignal.timeout(ms) aborts with 'TimeoutError'; branch on err.name, because the message text differs between engines. A controller is also single-use, since an aborted signal never returns to aborted === false, so a retry needs a new controller, whereas one signal handed to three fetches cancels all three from a single button.
function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
let id;
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
id = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve('slept ' + ms + 'ms');
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
const controller = new AbortController();
const { signal } = controller;
signal.addEventListener('abort', () => {
console.log('signal fired, aborted =', signal.aborted);
});
delay(5000, signal)
.then((value) => console.log('resolved:', value))
.catch((err) => console.log('rejected with', err.name));
console.log('before abort, aborted =', signal.aborted);
setTimeout(() => controller.abort(), 50);AbortController does not stop work; it broadcasts a cancellation request that the operation itself must be written to observe.
Worked examples
One signal, many operations
Shows a single abort() rejecting several pending jobs, and a second abort() doing nothing.
const controller = new AbortController();
const { signal } = controller;
function job(name) {
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error(name + ' cancelled')), { once: true });
});
}
const settled = Promise.allSettled([job('user'), job('posts')]);
let fired = 0;
signal.addEventListener('abort', () => { fired += 1; });
controller.abort();
controller.abort();
console.log('abort event fired', fired, 'time(s)');
settled.then((results) => {
for (const r of results) console.log(r.status, '/', r.reason.message);
});Example explained
Line 1Both jobs subscribe to the same signal, so one abort() call rejects both without any per-job bookkeeping.
Line 2The second abort() is ignored: a signal that is already aborted never dispatches a second event, so fired stays 1.
Line 3{ once: true } lets each handler unregister itself, which keeps a long-lived signal from accumulating listeners.
Line 4The reason here is a plain Error the job chose, not an AbortError, because the job builds its own rejection value.
Checking between awaits
Demonstrates cooperative cancellation of a multi-step loop with signal.throwIfAborted().
const controller = new AbortController();
const { signal } = controller;
const step = () => new Promise((resolve) => setTimeout(resolve, 20));
async function processAll(items) {
for (const item of items) {
await step();
signal.throwIfAborted();
console.log('processed', item);
}
console.log('all done');
}
processAll(['a', 'b', 'c', 'd']).catch((err) => console.log('stopped early:', err.name));
setTimeout(() => controller.abort(), 50);Example explained
Line 1await step() is where the time goes, and the signal cannot interrupt it, so the check has to sit after the await.
Line 2throwIfAborted() throws signal.reason, which turns the loop into a rejection of the async function.
Line 3Two steps finish inside the 50 ms budget, so the third check is the one that throws and 'all done' never runs.
Line 4Work already done is not undone: 'a' and 'b' stay processed after the abort.
A deadline instead of a button
Uses AbortSignal.timeout to cancel on a clock and shows that its reason is a TimeoutError, not an AbortError.
function work(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => resolve('finished in ' + ms + 'ms'), ms);
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(signal.reason);
}, { once: true });
});
}
const signal = AbortSignal.timeout(30);
work(500, signal)
.then((value) => console.log(value))
.catch((err) => console.log('gave up:', err.name));Example explained
Line 1AbortSignal.timeout(30) hands back a signal with no controller attached, so only the clock can abort it.
Line 2Its reason is a DOMException named TimeoutError, which lets one catch block separate a deadline from a user cancel.
Line 3clearTimeout(id) is the part that does the real cancelling; rejecting alone would leave the 500 ms timer running.
Line 4The promise settles at 30 ms even though the work asked for 500 ms, because the abort path rejects first.
Important notes
abort() does not undo side effects. A cancelled fetch may already have reached the server and been processed; only the response is discarded.
AbortSignal.timeout and AbortSignal.any are much newer than AbortController itself, so check runtime support before relying on them.
Common mistakes
Reusing one controller across retries: the signal is still aborted, so the retry rejects before it sends anything.
Writing fetch(url, signal) instead of fetch(url, { signal }): fetch sees no signal option, abort() has no effect, and the request quietly completes.
Treating the AbortError in catch like a network error: the UI shows a failure message, or retries, for work the user deliberately cancelled.
Try it yourself
Change, predict, then run
Write search(term) that keeps its AbortController in a variable outside the function and aborts the previous call before starting a new one, using a fake lookup(term, signal) that resolves after 300 ms unless aborted. Call search('a'), search('ab') and search('abc') back to back and confirm only 'abc' logs a result while the first two log AbortError.
Open the JavaScript workspaceCheck your understanding
You wrap setTimeout in a new Promise and pass an AbortSignal into the function, but the function body never reads that signal. What happens when you call controller.abort() before the timer fires?
- The promise rejects immediately with an AbortError DOMException.
- The promise stays pending forever, because the abort blocks its resolution.
- Nothing changes: the timer fires and the promise resolves normally.
- abort() throws, because the signal has no registered abort handler.
Show answer
abort() only flips signal.aborted, fills in signal.reason and dispatches one 'abort' event; it holds no reference to your promise and no power over your timer. Option 0 is what you observe with fetch, but that rejection comes from fetch's own code watching the signal, so an operation that never reads the signal simply is not cancellable.