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.
- 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 cancelledpackage main
import (
"context"
"errors"
"fmt"
"time"
)
// sleep waits for d, or returns early when ctx is cancelled — like
// setTimeout from node:timers/promises with { signal }.
func sleep(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil) // always call cancel to release the context's resources
// cancel after 100ms, while the work needs a full second
time.AfterFunc(100*time.Millisecond, func() {
cancel(errors.New("user cancelled"))
})
if err := sleep(ctx, 1*time.Second); err != nil {
fmt.Println(err) // → context canceled
fmt.Println(errors.Is(err, context.Canceled)) // → true
fmt.Println(context.Cause(ctx)) // → 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
}package main
import (
"context"
"errors"
"fmt"
"time"
)
func sleep(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) // cancels itself after 100ms
defer cancel()
err := sleep(ctx, 1*time.Second)
fmt.Println(err) // → context deadline exceeded
fmt.Println(errors.Is(err, context.DeadlineExceeded)) // → true
fmt.Println(errors.Is(err, context.Canceled)) // → false
}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 timeoutpackage main
import (
"context"
"errors"
"fmt"
"time"
)
func sleep(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func download(userDelay time.Duration) string {
user, cancelUser := context.WithCancelCause(context.Background())
defer cancelUser(nil)
// child context: cancelled when its parent (user) is cancelled OR after 200ms
ctx, cancel := context.WithTimeout(user, 200*time.Millisecond)
defer cancel()
timer := time.AfterFunc(userDelay, func() { cancelUser(errors.New("user cancelled")) })
defer timer.Stop()
if err := sleep(ctx, 1*time.Second); err != nil {
return fmt.Sprint("aborted: ", context.Cause(ctx))
}
return "done"
}
func main() {
fmt.Println(download(50 * time.Millisecond)) // → aborted: user cancelled
fmt.Println(download(500 * time.Millisecond)) // → aborted: context deadline exceeded
// two contexts with NO parent-child relationship (e.g. server shutdown
// and a single request): link them with context.AfterFunc
shutdown, stopServer := context.WithCancelCause(context.Background())
request, cancelRequest := context.WithCancelCause(context.Background())
defer cancelRequest(nil)
stop := context.AfterFunc(shutdown, func() {
cancelRequest(context.Cause(shutdown))
})
defer stop()
stopServer(errors.New("server shutting down"))
<-request.Done()
fmt.Println(context.Cause(request)) // → server shutting down
}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: TimeoutErrorpackage main
import (
"context"
"fmt"
"time"
)
func processItems(ctx context.Context, items []int) error {
for _, item := range items {
if err := ctx.Err(); err != nil { // non-nil once ctx is cancelled, ~ throwIfAborted()
return err
}
time.Sleep(50 * time.Millisecond) // simulate processing one item
fmt.Println("processed", item)
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 125*time.Millisecond)
defer cancel()
if err := processItems(ctx, []int{1, 2, 3, 4, 5}); err != nil {
fmt.Println("stopped:", err)
}
}
// → processed 1
// → processed 2
// → processed 3
// → stopped: context deadline exceededKey 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 |