AbortController sends a notification; it cannot force a promise or CPU loop to stop. A callback must inspect the signal, subscribe to it when waiting, and release listeners or timers on either success or abort. A signal may already be aborted before the callback starts.
The library’s contract must also say when its outer promise settles. Our final pool stops starting new jobs on first observed failure, aborts active peers, then waits for active callbacks to settle before rejecting. This provides a useful cleanup boundary, but a callback that never settles can prevent the pool from settling forever.
Use signal.throwIfAborted() for a precondition in modern browsers and Node 22+. It throws the signal’s reason, which can be any value, not necessarily Error. If your public API preserves that reason, do not accidentally replace it with a later cleanup failure. The example manually aborts a pending timer; no real network access is involved.
Predict, run, and explain
Write the output and trace the important state changes before checking it. Then run the program. Each editor is independent. Async examples end with main() so this editor waits for their work.
async function main() {
function wait(signal) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve("done");
}, 100);
signal.addEventListener("abort", onAbort, { once: true });
});
}
const controller = new AbortController();
const pending = wait(controller.signal);
controller.abort("stopped");
try {
await pending;
} catch (reason) {
console.log(reason);
}
}
main();Check your prediction
stopped Cancellation cannot settle an uncooperative callback
A signal communicates a request to stop. It does not force a callback to reject or return. A library that promises to await active callbacks must still wait when one ignores its signal. This settlement policy is as important as the decision to stop starting new work.
Pre-abort and pending abort are different paths. The first can prevent work from starting; the second must notify work already in progress and release any installed listener when it settles. Check both paths rather than treating one as evidence for the other.
async function main() {
const controller = new AbortController();
let starts = 0;
controller.abort("stop");
async function work(signal) {
signal.throwIfAborted();
starts++;
}
try {
await work(controller.signal);
} catch (reason) {
console.log(reason);
}
console.log(starts);
}
main();Check your prediction
stop
0 The abort check precedes the side effect. Putting it after starts++ would observe cancellation but fail the no-start contract.
async function main() {
const controller = new AbortController();
async function ignoresSignal() {
await Promise.resolve();
return "finished";
}
const work = ignoresSignal();
controller.abort();
console.log(await work);
console.log(controller.signal.aborted);
}
main();Check your prediction
finished
true The controller changed state, but the unrelated callback still fulfilled. A public adapter may suppress that late value, yet cannot undo side effects the callback already performed.
Try the rule on new inputs
Write separate pre-abort and mid-flight fixtures. Count starts, verify the exact reason and track listener removal. State whether your library waits for active work before returning cancellation.
Your turn
Cancellation & Settlement: practise
Write an async operation that checks an already-aborted signal before invoking its work callback. Abort with “cancelled”, attempt the operation and print cancelled followed by 0 for callback starts.
// Check abort before starting work; catch the awaited failure inside async main.Hint 1: the idea
A pre-aborted request must not start work and then notice cancellation.
Hint 2: a first step
Call signal.throwIfAborted before invoking the supplied function.
Show one solution
async function main() {
async function run(work, signal) {
signal.throwIfAborted();
return await work();
}
let starts = 0;
const controller = new AbortController();
controller.abort("cancelled");
try {
await run(() => {
starts++;
return 1;
}, controller.signal);
} catch (reason) {
console.log(reason);
}
console.log(starts);
}
main();cancelled
0Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?
Change it and test it
Test a non-aborted operation. For the timer example, change abort to normal completion and verify the listener is removed. Explain why wrapping a never-settling third-party promise does not magically make its underlying work cancellable.
Completion is saved in this browser only. It does not measure mastery.