Skip to content
academia.sh

Lesson 11 / 17

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.

Contents

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.

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.

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.

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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close