JavaScript Basics: Cancel Waiting with AbortController
An asynchronous task may become unnecessary before it finishes. An AbortController lets you signal cancellation to APIs that support it. Here, cancel a promise-based timer without waiting for its full delay.
These examples use built-in features and were tested on Linux with Node.js v24.19.0. No packages are needed. Assertions check the results before the program finishes.
Prepare a scratch directory
You need Bash, a text editor, Node.js on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. These commands stop the shell if setup fails. Save each named source file as UTF-8; .mjs selects ES modules.
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 11. Cancel a pending timer
Save this as cancel-wait.mjs:
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
const controller = new AbortController();
const waiting = delay(10_000, 'finished', { signal: controller.signal });
controller.abort();
await assert.rejects(waiting, { name: 'AbortError' });
assert.equal(controller.signal.aborted, true);
console.log('Timer rejected with AbortError');
console.log(`Signal aborted: ${controller.signal.aborted}`);Run it:
node cancel-wait.mjsOutput:
Timer rejected with AbortError
Signal aborted: trueNode’s promise-based timers accept an AbortSignal and reject with AbortError when cancelled. The call to abort() happens synchronously before the timer can complete. assert.rejects() handles and verifies that rejection; in application code, handle the expected cancellation and rethrow unexpected errors.
2. Preserve the cancellation reason
Save this as cancellation-reason.mjs:
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
const controller = new AbortController();
const reason = new Error('The user cancelled the task');
controller.abort(reason);
assert.equal(controller.signal.reason, reason);
assert.throws(() => controller.signal.throwIfAborted(), error => error === reason);
await assert.rejects(
delay(10_000, undefined, { signal: controller.signal }),
error => error.name === 'AbortError' && error.cause === reason,
);
console.log(`Reason: ${controller.signal.reason.message}`);
console.log('Already-aborted timer rejected');Run it:
node cancellation-reason.mjsOutput:
Reason: The user cancelled the task
Already-aborted timer rejectedThe signal retains its reason. throwIfAborted() throws that reason directly. This Node timer API instead rejects with an AbortError whose cause is the reason; other APIs can report cancellation differently. Checking a signal before starting work also handles cancellation that occurred earlier.
3. Use a fresh controller for a fresh task
Save this as fresh-cancellation.mjs:
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
const cancelledController = new AbortController();
cancelledController.abort();
await assert.rejects(
delay(10_000, 'old task', { signal: cancelledController.signal }),
{ name: 'AbortError' },
);
const freshController = new AbortController();
const result = await delay(1, 'new task finished', {
signal: freshController.signal,
});
assert.equal(result, 'new task finished');
assert.equal(freshController.signal.aborted, false);
console.log(result);
console.log(`Fresh signal aborted: ${freshController.signal.aborted}`);Run it:
node fresh-cancellation.mjsOutput:
new task finished
Fresh signal aborted: falseAn aborted signal cannot be reset. Create a new controller for a new independently cancellable task. The one-millisecond delay is a requested delay, not an exact scheduling guarantee, so the output deliberately contains no measured duration.
Cancellation is cooperative: it does not stop an arbitrary promise or interrupt synchronous JavaScript. The operation must support the signal, or your own code must check it. It also does not undo effects that already occurred.
Clean up
In the same terminal, remove only the example files, return to your original directory, and remove the empty scratch directory:
rm -- cancel-wait.mjs cancellation-reason.mjs fresh-cancellation.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.