Skip to content
JavaScript Basics: Cancel Waiting with AbortController

JavaScript Basics: Cancel Waiting with AbortController

October 17, 2026

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 1

1. 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.mjs

Output:

Timer rejected with AbortError
Signal aborted: true

Node’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.mjs

Output:

Reason: The user cancelled the task
Already-aborted timer rejected

The 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.mjs

Output:

new task finished
Fresh signal aborted: false

An 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_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on