DEV Community

Cover image for AbortSignal.timeout() and AbortSignal.any(): Cancel Any Async Call in TypeScript
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

AbortSignal.timeout() and AbortSignal.any(): Cancel Any Async Call in TypeScript


Your function promised to give up after two seconds, and it did. It threw a nice Error("timed out"), the caller logged it, the user saw a retry button. Meanwhile the HTTP request it was supposed to abandon kept downloading for another forty seconds, holding a socket, filling a buffer nobody would ever read.

That is what a hand-rolled timeout built on setTimeout and Promise.race does. It stops waiting. It never stops the work.

The fix has been sitting in every runtime for a while. Node.js added AbortSignal.timeout() in v17.3.0 and v16.14.0, and AbortSignal.any() in v20.3.0 and v18.17.0. MDN marks AbortSignal.any() as available across browsers since March 2024, and AbortSignal.timeout() since April 2024. Deno and Bun document both too.

Every snippet below is plain TypeScript that runs with node file.ts. Node's type stripping is on by default from v23.6.0 and v22.18.0, and stable since v25.2.0 and v24.12.0. On an older Node, use tsx. The snippets use top-level await, so run them in a folder whose package.json has "type": "module".

The setTimeout-and-a-flag version, and what it leaks

Here is the pattern you will find in a lot of codebases:

// race.ts: the hand-rolled timeout
async function getJson(url: string, ms: number) {
  let timedOut = false;
  const timer = new Promise<never>((_, reject) => {
    setTimeout(() => {
      timedOut = true;
      reject(new Error(`timed out after ${ms}ms`));
    }, ms);
  });
  const work = fetch(url).then((res) => {
    if (timedOut) return undefined; // too late, drop it
    return res.json();
  });
  return Promise.race([work, timer]);
}
Enter fullscreen mode Exit fullscreen mode

Read it as the runtime does. Promise.race settles with whichever promise finishes first, and that is all it does. It has no way to reach into the fetch and stop it. The timedOut flag only protects your code from using a late result. The connection, the response stream and the JSON parse you skipped are still the runtime's problem.

There is a second leak in the happy path. When work wins, the timer is never cleared, so the pending setTimeout keeps the process alive until it fires, long after the response is in. A CLI built this way sits there doing nothing until the timer runs out.

Bun's own linter has a rule for the controller-plus-timer variant of this, prefer-timeout-signal, which flags an AbortController that only a setTimeout aborts.

AbortSignal.timeout(): one call, one deadline

fetch takes a signal option. Hand it a signal that aborts itself after a delay and the runtime tears down the request when the deadline hits:

// timeout.ts
try {
  const res = await fetch("https://example.com/", {
    signal: AbortSignal.timeout(2_000),
  });
  const body = await res.text();
  console.log(res.status, body.length);
} catch (err) {
  console.error("request failed:", err);
}
Enter fullscreen mode Exit fullscreen mode

There is no timer to clear and no flag to check. Node's docs describe it as returning "a new AbortSignal which will be aborted in delay milliseconds" (globals docs).

The reason it aborts with matters for later. Per MDN, the signal's reason is a DOMException named TimeoutError. Deno's fetch timeout example spells out what that means for fetch: "When the deadline passes first, fetch rejects with a DOMException whose name is TimeoutError, not AbortError." A plain controller.abort() with no argument, by contrast, uses an AbortError DOMException as the reason (MDN).

Keep res.text() inside the try. The body arrives after the headers, and a slow body is exactly the case a deadline exists for.

One trap: the clock starts when you call AbortSignal.timeout(), not when you pass the signal to something. Create it per call. A module-level const DEADLINE = AbortSignal.timeout(5_000) is already aborted for every request made after the first five seconds of the process.

AbortSignal.any(): a user cancel and a deadline on one signal

Real calls rarely have one reason to stop. The user hits cancel, the server is shutting down, or the deadline passes. AbortSignal.any() takes a list of signals and returns one that aborts when any of them does. Per MDN, its reason is the reason of the first input that aborted.

// any.ts
const user = new AbortController();
process.once("SIGINT", () => user.abort());

const signal = AbortSignal.any([
  user.signal,
  AbortSignal.timeout(10_000),
]);

try {
  const res = await fetch("https://example.com/", { signal });
  console.log(res.status, (await res.text()).length);
} catch (err) {
  console.error("stopped:", signal.reason);
}
Enter fullscreen mode Exit fullscreen mode

Press Ctrl-C during the request and the combined signal aborts with the AbortError from user.abort(). If the request is still running at ten seconds, it aborts with the TimeoutError.

This composes well with retries. Each attempt gets its own short deadline, and all of them share the user's cancel and one overall budget:

// retry.ts
async function getWithRetry(
  url: string,
  outer: AbortSignal,
): Promise<Response> {
  const budget = AbortSignal.timeout(8_000);
  for (let attempt = 1; ; attempt++) {
    const signal = AbortSignal.any([
      outer,
      budget,
      AbortSignal.timeout(2_000),
    ]);
    try {
      return await fetch(url, { signal });
    } catch (err) {
      // the caller or the budget gave up: stop retrying
      if (outer.aborted || budget.aborted) throw err;
      if (attempt === 3) throw err;
    }
  }
}

const user = new AbortController();
const res = await getWithRetry(
  "https://example.com/",
  user.signal,
);
console.log(res.status);
Enter fullscreen mode Exit fullscreen mode

The returned body is still bound to those deadlines. If it can be slow, read it inside getWithRetry, or the 2-second attempt timer can fire mid-download.

Notice the check inside catch. It asks the signals what happened instead of guessing from the error.

Make your own async functions cancellable

Passing signal to fetch covers the network. Your own loops, polls and multi-step jobs need to cooperate too, and signal.throwIfAborted() is the one-liner for that. It throws the signal's reason if the signal has aborted and does nothing otherwise (MDN).

The pattern: accept a signal, check it before each unit of work, and pass it down to everything that accepts one.

// poll.ts
import { setTimeout as sleep } from "node:timers/promises";

type Job = { id: string; done: boolean };

export async function waitForJob(
  check: (signal: AbortSignal) => Promise<Job>,
  signal: AbortSignal,
): Promise<Job> {
  while (true) {
    signal.throwIfAborted();
    const job = await check(signal);
    if (job.done) return job;
    await sleep(500, undefined, { signal });
  }
}

let calls = 0;
const check = async (): Promise<Job> => {
  calls += 1;
  return { id: "job-1", done: calls > 100 };
};

const deadline = AbortSignal.timeout(1_200);
try {
  await waitForJob(check, deadline);
} catch (err) {
  console.error("gave up:", deadline.reason);
}
Enter fullscreen mode Exit fullscreen mode

Three places can stop this loop: the throwIfAborted() at the top of each iteration, the check function if it forwards the signal to a fetch, and the sleep. timers/promises accepts a signal and, when it is cancelled, rejects with an 'AbortError'.

The deadline above fires while the loop is sleeping, so the error that reaches your catch comes from the sleep. Node's errors docs say Node APIs throw "a Node.js AbortError", a regular Error with name 'AbortError' and code 'ABORT_ERR'. Its name says cancelled even though the cause was a timeout. The reliable place to look is deadline.reason.

Narrow the error without a cast

With strict on, the variable in a catch clause is unknown (useUnknownInCatchVariables, TypeScript 4.4), and err.name does not compile. The usual escape hatch is (err as DOMException).name, which compiles and lies whenever the thing thrown was a Node AbortError, a TypeError from a refused connection, or the string you passed to controller.abort("shutdown"). The reason can be any JavaScript value.

TypeScript 4.9 made in narrowing work on types that do not list the property, which is all you need to read a name safely:

// abort-kind.ts
export type AbortKind = "timeout" | "cancelled" | "other";

export function errorName(err: unknown): string | undefined {
  if (
    typeof err === "object" &&
    err !== null &&
    "name" in err &&
    typeof err.name === "string"
  ) {
    return err.name;
  }
  return undefined;
}

export function abortKind(
  err: unknown,
  signal: AbortSignal,
): AbortKind {
  if (signal.aborted) {
    const why = errorName(signal.reason);
    return why === "TimeoutError" ? "timeout" : "cancelled";
  }
  const name = errorName(err);
  if (name === "TimeoutError") return "timeout";
  if (name === "AbortError") return "cancelled";
  return "other";
}
Enter fullscreen mode Exit fullscreen mode

abortKind checks the signal first. If the signal you own has aborted, its reason is the truth, whatever the error looks like by the time it bubbles up through a library or a Node timer. Only when the signal is still live does it fall back to the error's name, which covers a dependency that aborted on its own. Anything else is a real failure: a network error, a 500 you turned into an exception, a bug.

In practice the call site reads like this:

// use-kind.ts
import { abortKind } from "./abort-kind.ts";

const user = new AbortController();
const signal = AbortSignal.any([
  user.signal,
  AbortSignal.timeout(2_000),
]);

try {
  const res = await fetch("https://example.com/", { signal });
  console.log(res.status);
} catch (err) {
  const kind = abortKind(err, signal);
  if (kind === "other") throw err;
  console.log(`request ${kind}`);
}
Enter fullscreen mode Exit fullscreen mode

The .ts extension in the import is required when Node strips types itself (Node docs).

No casts. If someone later throws a custom error class or aborts with a plain string, the code still type-checks and still lands in a sensible branch.

Where this goes wrong

A few edges worth knowing before you roll it out.

The timeout counts active time. MDN notes the timer is effectively paused in a suspended worker or while a page sits in the back-forward cache (MDN). For a browser tab that comes back from bfcache, "two seconds" means two seconds of running.

A combined signal cannot be detached. MDN says there is no way to unsubscribe the result of AbortSignal.any() from its inputs, and that a non-aborted combined signal with abort listeners stays alive while its sources do. If you add a listener, remove it when the work finishes:

// listen.ts
export function onAbort(
  signal: AbortSignal,
  fn: () => void,
): () => void {
  signal.addEventListener("abort", fn, { once: true });
  return () => signal.removeEventListener("abort", fn);
}
Enter fullscreen mode Exit fullscreen mode

Call the returned function in a finally. That matters most for a long-lived signal, such as one tied to server shutdown, that thousands of requests combine with.

Code that ignores the signal keeps running. A synchronous CPU loop, a library without a signal option, or a function that forgets to pass it down will finish its work no matter what. Thread the signal through every layer you own and put throwIfAborted() between steps that do not take one.

Type checking. node file.ts strips types without checking them. When you run tsc, the declarations come from @types/node, which declares any(), timeout() and throwIfAborted(), or from the dom lib in a browser project. Set allowImportingTsExtensions (with noEmit) or rewriteRelativeImportExtensions so tsc accepts the .ts imports.


If this was useful

Most of this post is two things TypeScript Essentials spends real time on: async code that cleans up after itself, and narrowing an unknown into something you can trust without reaching for as. If the abortKind helper was the useful part, that book is the place to go next. If you already live in generics and want the type-level side, The TypeScript Type System picks up where it stops.

Books 1 and 2 are the core path. Coming from Kotlin or Java, or from PHP 8+, swap in book 3 or 4 for the first one. Book 5 is for anyone shipping TypeScript at work.

I also build Hermes IDE (GitHub), an IDE for developers who ship with Claude Code and other AI coding tools, so weigh this mention with that in mind.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (2)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The distinction you draw — it stops waiting, it never stops the work — is the one that bites in production, because a timed-out request that keeps running server-side is a duplicate write waiting for a retry to arrive. I learned this in document pipelines: a Lambda invocation that timed out client-side kept processing and wrote twice, and the fix was not a shorter timeout, it was making the abort signal reach the work instead of just the waiter. Your module-level timeout trap is the same class of mistake — a deadline created once is already dead for every call after the first five seconds, and it fails silently because the code still runs. The part I would push one step further is the write step itself: throwIfAborted at the top of the loop guards the next iteration, but the gap I watch is between "check says not done" and "write the result" — is that step guarded in your jobs, or is it the place where a late abort still lands a write?

Collapse
 
arhashir profile image
A.R. Hashir •

The listener leak you flag has a very specific symptom worth naming, because the warning points at the wrong place. Once you build composites against one long-lived signal in a loop, Node starts printing:

MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]

Nothing in that message mentions your retry loop, so people go hunting in the wrong file. And the count is per-signal — it's the outer signal crossing 10 listeners, not the composite, which makes it look unrelated when you're staring at a composite.

The fix I've landed on is to keep the outer signal flat: pass the raw user signal down and create exactly one composite per request scope, rather than layering any(signal, timeout) again at every step down the stack.

Curious whether you've hit this in a queue or worker pool where the outer signal outlives thousands of requests, or whether Hermes keeps the composition deliberately shallow to sidestep it.