jsrosetta

Async

Cancellation: AbortController and context

How Node.js's AbortController/AbortSignal compares to Go's context.Context: cancelling manually, timeouts, combining cancellation signals, and checking for cancellation inside a loop.

By
Minimum versions
Node.js ≥ 20.3Go ≥ 1.21
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

Once a promise is running there's no way to "stop" it from the outside — Node.js solves this with a signal that travels alongside it: AbortController creates an AbortSignal, whoever wants to cancel calls controller.abort(), and the function that received the signal listens for it and stops early. Go's context.Context plays exactly that role: ctx.Done() is a channel that gets closed when the context is cancelled, and ctx.Err() tells you why. Both are cooperative: nothing gets forcibly killed — the running code has to check the signal and stop itself. The biggest difference is how it propagates: a Go context is passed explicitly as the first parameter down the whole call tree, and every child context is cancelled automatically when its parent is; an AbortSignal is a standalone object, and combining several of them takes AbortSignal.any().

Cancelling manually (abort / cancel)

import { setTimeout as sleep } from "node:timers/promises";
 
const controller = new AbortController();
const { signal } = controller;
 
signal.addEventListener("abort", () => {
  console.log("abort event:", signal.reason.message); // → abort event: user cancelled
}, { once: true });
 
// cancel after 100ms, while the work needs a full second
setTimeout(() => controller.abort(new Error("user cancelled")), 100);
 
try {
  await sleep(1000, "done", { signal });
} catch (err) {
  console.log(err.name);          // → AbortError
  console.log(err.cause.message); // → user cancelled
}
 
console.log(signal.aborted);        // → true
console.log(signal.reason.message); // → user cancelled

Timeouts (AbortSignal.timeout / context.WithTimeout)

import { setTimeout as sleep } from "node:timers/promises";
 
const signal = AbortSignal.timeout(100); // aborts itself after 100ms
 
try {
  await sleep(1000, "done", { signal });
} catch (err) {
  console.log(err.name);           // → AbortError
  console.log(err.cause.name);     // → TimeoutError
  console.log(signal.reason.name); // → TimeoutError
}

Combining cancellation signals (AbortSignal.any / child contexts)

import { setTimeout as sleep } from "node:timers/promises";
 
async function download(userDelay) {
  const user = new AbortController();
  // abort when the user cancels OR after 200ms, whichever comes first
  const signal = AbortSignal.any([user.signal, AbortSignal.timeout(200)]);
 
  const timer = setTimeout(() => user.abort(new Error("user cancelled")), userDelay);
  try {
    return await sleep(1000, "done", { signal });
  } catch {
    return `aborted: ${signal.reason.name}: ${signal.reason.message}`;
  } finally {
    clearTimeout(timer);
  }
}
 
console.log(await download(50));  // → aborted: Error: user cancelled
console.log(await download(500)); // → aborted: TimeoutError: The operation was aborted due to timeout

Checking for cancellation inside a loop (throwIfAborted / ctx.Err)

import { setTimeout as sleep } from "node:timers/promises";
 
async function processItems(items, signal) {
  for (const item of items) {
    signal.throwIfAborted(); // throws signal.reason if already aborted
    await sleep(50);         // simulate processing one item
    console.log("processed", item);
  }
}
 
try {
  await processItems([1, 2, 3, 4, 5], AbortSignal.timeout(125));
} catch (err) {
  console.log("stopped:", err.name);
}
// → processed 1
// → processed 2
// → processed 3
// → stopped: TimeoutError

Key differences

Node.js Go
Create a cancellation signal new AbortController() context.WithCancel / WithCancelCause
Cancel controller.abort(reason) cancel() / cancel(cause)
Timeout AbortSignal.timeout(ms) context.WithTimeout(parent, d)
Why it was cancelled signal.reason ctx.Err() + context.Cause(ctx)
Check inside a loop signal.throwIfAborted() ctx.Err() != nil
Combine signals AbortSignal.any([...]) child contexts (parent cancelled → child cancelled), context.AfterFunc
Propagation pass { signal } where needed pass ctx as the first parameter through the whole call tree