---
title: 'Cancellation and Timeouts'
source: 'https://academia.sh/en/courses/asynchronous-javascript/cancellation-and-timeouts'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:44+00:00'
license: 'CC BY-SA 4.0'
---

# Cancellation and Timeouts

The abort signal concept, actually stopping a request, building a timeout on top of the signal, why a race-based timeout falls short, and combining signals.

The previous lesson closed with two gaps: a source that never responds is waited on
forever, and there is no way to give up on a request that has already started. Both
gaps come from the same absence — there is no channel to affect an asynchronous
operation *from outside*.

This lesson builds that channel. Its name is the **abort signal**; a timeout is a
special case built on top of it. The measurement stream becomes stoppable here.

## The Abort Signal

The cancellation mechanism consists of two objects. The **controller** sits on the side
that starts the cancellation and carries an `abort` method. The **signal** is handed to
the side doing the work; it reports whether cancellation has happened and emits an
event at the moment of cancellation.

The distinction matters: the side receiving the signal *cannot* cancel, it can only
*watch* for cancellation. This is how the authority to cancel stays with the caller.

```js
import { createServer } from "node:http";

const server = createServer((request, response) => {
  const id = setTimeout(() => {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ station: "A1", value: 21.4 }));
  }, 200);
  response.on("close", () => clearTimeout(id));
});

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;

const controller = new AbortController();
setTimeout(() => controller.abort(), 20);

try {
  await fetch(`${base}/measurement/A1`, { signal: controller.signal });
  console.log("this line never runs");
} catch (error) {
  console.log("request stopped:", error.name);
}

try {
  await fetch(`${base}/measurement/A1`, { signal: AbortSignal.timeout(20) });
} catch (error) {
  console.log("timeout:", error.name);
}

server.close();
```

```
request stopped: AbortError
timeout: TimeoutError
```

The server was set up to respond after two hundred milliseconds; both requests ended at
the twentieth millisecond. The error names tell the two cases apart: a manual abort
produces `AbortError`, a time limit produces `TimeoutError`. This distinction is used in
the recovery decision — if the user gave up, retrying is meaningless; if the time
ran out, it can be.

`AbortSignal.timeout` is a shortcut: it produces a signal that aborts on its own once
the given duration elapses. It removes the need to set up and cancel a timer by hand.

The `close` listener on the server side is the counterpart of the same idea: when the
client cuts the connection, the server also cancels its own pending work. Cancellation
is not a one-way call, it is a contract that has to be implemented on both ends.

## Adding Cancellation to Your Own Function

Cancellation is not the host environment's privilege. Any function returning a promise
can join the same contract by accepting a signal in its options object. There are
three rules: reject immediately if the signal is already aborted, release resources and
reject on the abort event, and register the listener to run only once.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
};

function fetchMeasurement(station, options = {}) {
  const signal = options.signal;
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }
    const record = SOURCE[station];
    const id = setTimeout(() => {
      console.log("source: work complete —", station);
      resolve({ station, value: record.value });
    }, record.delay);

    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(id);
        console.log("source: work stopped —", station);
        reject(signal.reason);
      },
      { once: true },
    );
  });
}

const controller = new AbortController();
setTimeout(() => controller.abort(new Error("collection cancelled")), 15);

try {
  await fetchMeasurement("A1", { signal: controller.signal });
} catch (error) {
  console.log("caller saw:", error.message);
}

await new Promise((resolve) => setTimeout(resolve, 40));
console.log("wait finished; no further output for A1");
```

```
source: work stopped — A1
caller saw: collection cancelled
wait finished; no further output for A1
```

The last line is proof that the cancellation really did stop the work: even though
forty more milliseconds were waited, the "work complete" message never printed, because
the timer was cleared.

The value given to the `abort` method sits in the signal's `reason` field and becomes
the rejection reason. If none is given, the host environment produces an error named
`AbortError`. Giving your own error is the most direct way to carry the cancellation
reason without a call stack.

## Why a Race-Based Timeout Falls Short

A timeout can also be written the first way that comes to mind once combinators are
known: racing the work against a timer. This fix cuts the waiting, but it does not cut
the work.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
};

function fetchMeasurement(station) {
  return new Promise((resolve) => {
    const record = SOURCE[station];
    setTimeout(() => {
      console.log("source: work completed anyway —", station);
      resolve({ station, value: record.value });
    }, record.delay);
  });
}

function timeoutAfter(duration) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error(`timeout: ${duration} ms`)), duration);
  });
}

try {
  await Promise.race([fetchMeasurement("A1"), timeoutAfter(10)]);
} catch (error) {
  console.log("caller saw:", error.message);
}

await new Promise((resolve) => setTimeout(resolve, 40));
console.log("wait finished");
```

```
caller saw: timeout: 10 ms
source: work completed anyway — A1
wait finished
```

The caller got the error at the tenth millisecond; the work completed at the thirtieth.
That is, the request sent to the server continued, the response arrived, memory and the
connection were held — only no one used the result.

The consequences fall into three headings: the source is spent for nothing, the
unsettled promise and the values it closes over stay in memory, and in an operation
with a side effect — writing a record, say — the operation said to have "timed out" may
actually have completed.

The rule is this: **a race ends the waiting, cancellation ends the work.** The two are
not interchangeable; a correct timeout is one that triggers cancellation once the
duration elapses.

## Combining Signals

An operation can have more than one reason to cancel: the user gave up, the time ran
out, a higher-level operation was cancelled. `AbortSignal.any` gathers multiple signals
into a single signal; whichever aborts first, the combined signal aborts with that
one's reason.

```js
const user = new AbortController();
const combined = AbortSignal.any([user.signal, AbortSignal.timeout(30)]);

combined.addEventListener("abort", () => {
  console.log("combined signal stopped; reason:", combined.reason.name);
});

setTimeout(() => user.abort(), 10);

await new Promise((resolve) => setTimeout(resolve, 50));
console.log("user signal aborted:", user.signal.aborted);
```

```
combined signal stopped; reason: AbortError
user signal aborted: true
```

Because the user's cancellation arrived at the tenth millisecond, the combined signal
settled with that one; the thirty-millisecond time limit never came into play. This
pattern makes it possible to carry cancellation through a call tree: a top-level
operation's signal is passed down, combined with each sub-operation's own time limits.

## The Limits of Cancellation

Two limits need to be known explicitly.

Cancellation **relies on cooperation.** A function that does not watch the signal
cannot be cancelled; the cancellation request only reaches those listening for it.
This is why every function returning a promise should accept a signal parameter for
work that might run long.

Cancellation **is not undoing.** A request's effect once it has reached the server is
not undone by cancellation; only waiting for the response is given up on. In operations
with side effects, this distinction is the basis of the next lesson's retry decision.

## Summary

- Cancellation is set up with two objects, a controller and a signal; the side
  receiving the signal only watches for cancellation.
- Your own functions returning a promise can join the same contract by accepting a
  signal and releasing their resources on the abort event.
- `AbortSignal.timeout` produces a time limit as a signal; a manual abort gives
  `AbortError`, a time limit gives `TimeoutError`.
- A race-based timeout only cuts the waiting; the work continues, the resource is held,
  a side effect can still occur.
- `AbortSignal.any` gathers multiple cancellation reasons into one signal and lets
  cancellation be carried through a call tree.

## Next Step

Cancellation and timeouts made failure well-defined: it is now known when a request
will end. What remains is what to do after a failure. The next lesson separates
transient errors from permanent ones, determines which operations can safely be
retried, shows why wait times are chosen with growth, and why a retry budget is kept
limited. The measurement stream thereby becomes both stoppable and resilient.
