Async Context & Cancellation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the
differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and
stability levels were checked against the official documentation on 2026-10-03, and every example was run on all three lines
unless the text says otherwise. Three related problems are covered here: knowing which request an asynchronous callback belongs
to (async context), telling work to stop (cancellation), and making sure resources are released however a block ends (explicit
resource management). The stability levels differ by API. The AsyncLocalStorage and AsyncResource classes, which live in
node:async_hooks but are documented on the "Asynchronous context tracking" page, are Stability 2 (Stable); within the class,
asyncLocalStorage.enterWith(), disable() and exit() are Stability 1 (Experimental), and the newer withScope() and
RunScope are Stability 1 (Experimental). The rest of node:async_hooks (createHook(), executionAsyncId() and the
other hook APIs) is Stability 1 (Experimental) and the documentation tells readers to migrate away from it if they can.
AbortController and AbortSignal are Stable (no longer experimental since v15.4.0). The Symbol.dispose and
Symbol.asyncDispose methods on Node.js objects (file handles, directories, timers, child processes, servers and streams) are
no longer experimental since v24.2.0. DEP0209 is a documentation-only deprecation introduced in v26.8.2.
The cancellation and disposal topics build on Timers, Events & EventEmitter, Callbacks, Promises & async/await and the event loop; the process-spawning details are in Child Processes and the HTTP client side in fetch & HTTP Clients. The running scenario is the Bookshelf application that grows across this section.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Added in v13.10.0 and v12.17.0; Stable since v16.4.0. |
|
Stable |
Added in v19.8.0 and v18.16.0; marked stable in v23.11.0 and v22.15.0. |
|
Stable |
Options added in v24.0.0; not available on Node.js 22. |
|
1 Experimental |
Present on every line; avoid |
|
1 Experimental |
Added in v25.9.0 and backported to v24.20.0; absent on Node.js 22. |
|
Default implementation |
Default since v24.0.0 (opt out with |
|
2 Stable |
Stable since v16.4.0; |
|
1 Experimental, migrate away |
The whole |
|
Stable |
Added in v15.0.0 and v14.17.0; |
|
Stable |
Added in v17.3.0 and v16.14.0. |
|
Stable |
Added in v20.3.0 and v18.17.0. |
|
Stable |
Became stable in v24.0.0 and v22.16.0. |
|
JavaScript language feature (V8) |
Syntax works on Node.js 24 and 26 (V8 13.6 and 14.6); a |
|
JavaScript language feature (V8) |
Globals on Node.js 24 and 26; not defined on Node.js 22. |
DEP0209 |
Documentation-only deprecation |
v26.8.2: using |
AsyncLocalStorage: state that follows the asynchronous flow
A server handles many requests at once on one thread, so a module-level variable cannot hold "the current request": the next
request overwrites it while the previous one is still waiting for the database. Other languages solve this with thread-local
storage. Node.js offers AsyncLocalStorage, which the documentation describes as similar to thread-local storage: a value
stored with run() is visible to the callback and to everything that callback starts, through callbacks, timers and promise
chains, and is invisible elsewhere. Each AsyncLocalStorage instance keeps its own independent context, so a logging module, a
tenant module and a tracing library can each hold one without interfering.
The pattern for a web service is to create the storage once at module level, call run(store, callback) at the edge of the
request (the HTTP handler) and call getStore() anywhere below it. The example gives every request an id, takes it from an incoming header
or generates one, and lets a logging helper print it without any function in between receiving it as a parameter. This
is the basis of log correlation: every line written while serving a request carries the same identifier, so concurrent
requests can be separated again when reading the logs.
import { createServer } from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
const requestContext = new AsyncLocalStorage();
function log(message) {
const ctx = requestContext.getStore();
console.log(JSON.stringify({ requestId: ctx?.requestId ?? '-', message }));
}
async function findBook(id) {
await sleep(10); // stands in for a database call
log(`loaded book ${id}`); // no requestId argument is passed down
return { id, title: 'Bookshelf' };
}
const server = createServer((req, res) => {
const requestId = req.headers['x-request-id'] ?? randomUUID();
requestContext.run({ requestId }, async () => {
log(`${req.method} ${req.url}`);
const book = await findBook(1);
res.setHeader('x-request-id', requestId);
res.end(JSON.stringify(book));
log('done');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
await Promise.all(
['a', 'b'].map((id) =>
fetch(`http://127.0.0.1:${port}/books/1`, { headers: { 'x-request-id': id } }).then((r) => r.text()),
),
);
server.close();
{"requestId":"a","message":"GET /books/1"}
{"requestId":"b","message":"GET /books/1"}
{"requestId":"a","message":"loaded book 1"}
{"requestId":"a","message":"done"}
{"requestId":"b","message":"loaded book 1"}
{"requestId":"b","message":"done"}
A structured logger works the same way: it calls getStore() when it formats a record and adds the request id to it. How that fits
a full logging and tracing setup is in Diagnostic Reports, Logging & Observability.
The API in one place
| Member | Behaviour |
|---|---|
|
Runs |
|
Returns the current store, or |
|
Runs a callback with no store (Experimental). |
|
Replaces the store for the rest of the current synchronous execution and the asynchronous work that follows (Experimental). It leaks
into whatever runs next in that execution, for example the next listener of an event, so prefer |
|
Disables the instance until the next |
|
A name for the instance and the value |
|
Returns a function that always runs |
|
Returns a function that runs any function it is given inside the context captured at the time of the call. |
name and defaultValue lines print undefined because the options do not exist there)import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
const als = new AsyncLocalStorage({ name: 'tenant', defaultValue: { tenant: 'none' } });
console.log('outside run:', als.getStore());
als.run({ tenant: 'acme' }, () => {
console.log('inside run:', als.getStore());
setTimeout(() => console.log('timer:', als.getStore()), 5);
queueMicrotask(() => console.log('microtask:', als.getStore()));
sleep(1).then(() => console.log('promise:', als.getStore()));
// exit() runs a callback with no store
als.exit(() => console.log('exit():', als.getStore()));
});
// Nested runs, the inner store wins and the outer one is restored
als.run({ tenant: 'outer' }, () => {
als.run({ tenant: 'inner' }, () => console.log('nested:', als.getStore()));
console.log('restored:', als.getStore());
});
// snapshot() and bind()
const snap = als.run({ tenant: 'captured' }, () => AsyncLocalStorage.snapshot());
als.run({ tenant: 'later' }, () => console.log('snapshot:', snap(() => als.getStore())));
const bound = als.run({ tenant: 'bound' }, () => AsyncLocalStorage.bind(() => als.getStore()));
console.log('bind:', bound());
console.log(als.name);
outside run: { tenant: 'none' }
inside run: { tenant: 'acme' }
exit(): undefined
nested: { tenant: 'inner' }
restored: { tenant: 'outer' }
snapshot: { tenant: 'captured' }
bind: { tenant: 'bound' }
tenant
microtask: { tenant: 'acme' }
promise: { tenant: 'acme' }
timer: { tenant: 'acme' }
Because run() is synchronous and returns the callback’s value, an async callback returns its promise and the caller can
await it; the store lives as long as the work started inside the callback. The documentation’s advice for a single awaited
operation is await asyncLocalStorage.run(new Map(), () ⇒ foo()).
withScope(), enterWith() and their pitfalls
enterWith() changes the store without a callback, and the documentation warns that the transition lasts for the entire
synchronous execution, so a store entered inside one event listener is seen by later listeners for the same event. Node.js 25.9
added withScope(), a block-scoped alternative built on explicit resource management (see below): it returns a disposable RunScope
that restores the previous store when the using block ends, even when an exception is thrown. It is Experimental, was backported to
v24.20.0 and does not exist on Node.js 22 (where the using syntax itself is a SyntaxError). The documentation also warns that
using it in an async function before the first await changes the caller’s context, so for asynchronous code run() remains the right tool.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
console.log(typeof als.withScope);
if (typeof als.withScope === 'function') {
{
using _ = als.withScope({ user: 'ada' });
console.log('in block:', als.getStore());
}
console.log('after block:', als.getStore());
}
// enterWith leaks into the rest of the synchronous execution
als.enterWith({ user: 'grace' });
console.log('enterWith:', als.getStore());
als.disable();
console.log('after disable:', als.getStore());
function
in block: { user: 'ada' }
after block: undefined
enterWith: { user: 'grace' }
after disable: undefined
AsyncContextFrame in Node.js 24 and later
Until Node.js 22 the default implementation of AsyncLocalStorage relied on async_hooks. Node.js 24 made an implementation backed by
AsyncContextFrame the default; the documented API is the same. The command-line documentation records the switch: --no-async-context-frame (added v24.0.0) turns the new implementation off and
uses the prior one based on async_hooks, retained for compatibility with Electron and for cases where the context flow may differ,
and --experimental-async-context-frame (added v22.7.0, Stability 1) lets Node.js 22 try the new model.
The documentation asks to report any difference in how context flows. The two implementations do not behave identically in every
corner: in the run below, defaultValue is returned outside run() and exit() returns undefined with the new implementation, while
with --no-async-context-frame the two are swapped. The ordinary run()/getStore() flow shown above is the same in both, so
treat defaultValue and exit() as details not to rely on.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage({ defaultValue: 'default' });
als.run('inside', () => {
console.log('getStore :', als.getStore());
als.exit(() => console.log('exit() :', als.getStore()));
});
console.log('outside :', als.getStore());
$ node frame-diff.mjs
getStore : inside
exit() : undefined
outside : default
$ node --no-async-context-frame frame-diff.mjs
getStore : inside
exit() : default
outside : undefined
When context is lost
Most code keeps its store. The documentation’s troubleshooting section says that in rare cases the store is lost in one
asynchronous operation; the remedies are to promisify() callback-based code so it uses native promises, or, for callback APIs and custom
thenables, to bind the callback with AsyncResource so that it runs in the right context. To find the culprit, log
getStore() after each suspect call: the last callback that prints undefined is probably responsible. A typical cause is a pool or queue that
stores a callback and invokes it later from its own context, as in the next example.
async_hooks and AsyncResource
node:async_hooks is the older, lower-level layer. Its createHook() API observes the lifecycle of every asynchronous resource
(init, before, after, destroy, promiseResolve), and executionAsyncId() and triggerAsyncId() identify the current
resource and the one that created it. The documentation marks this API Stability 1 (Experimental) with the note "Please migrate
away from this API, if you can", and the page warns that tracking promise execution can cause significant performance overhead. Use AsyncLocalStorage for context
(for observability see Diagnostic Reports, Logging & Observability);
reach for createHook() only to debug resource leaks, and never to carry application state.
AsyncResource (Stable) remains useful for library authors: a class that queues work and runs callbacks later extends it, or binds
callbacks with AsyncResource.bind() or AsyncLocalStorage.bind(), so that each callback runs in the context of the code that
registered it. events.EventEmitterAsyncResource does the same for event emitters.
import { AsyncLocalStorage, AsyncResource, createHook, executionAsyncId } from 'node:async_hooks';
import { writeSync } from 'node:fs';
const kinds = new Map();
const hook = createHook({
init(asyncId, type) { kinds.set(type, (kinds.get(type) ?? 0) + 1); },
}).enable();
setTimeout(() => {}, 1);
await Promise.resolve();
hook.disable();
writeSync(1, `executionAsyncId: ${executionAsyncId()}\n`);
writeSync(1, `seen types: ${['PROMISE', 'Timeout'].map((t) => `${t}=${kinds.has(t)}`).join(' ')}\n`);
// A callback-style queue that would lose the request context unless the callback is bound
const als = new AsyncLocalStorage();
const queue = [];
const enqueue = (callback) => queue.push(callback);
als.run('request-1', () => enqueue((v) => console.log('plain callback :', als.getStore(), v)));
als.run('request-2', () => enqueue(AsyncLocalStorage.bind((v) => console.log('ALS.bind :', als.getStore(), v))));
als.run('request-3', () => enqueue(AsyncResource.bind((v) => console.log('AsyncResource.bind:', als.getStore(), v))));
for (const callback of queue) callback(42);
executionAsyncId() is not meaningful to compare)executionAsyncId: 4
seen types: PROMISE=true Timeout=true
plain callback : undefined 42
ALS.bind : request-2 42
AsyncResource.bind: request-3 42
Source: Node.js API: Async hooks
AbortController and AbortSignal
Cancellation in Node.js is cooperative and uses the same web-standard primitives as browsers. An AbortController owns the right to
cancel and exposes a signal (an AbortSignal, which is an EventTarget) that is handed to the work. Calling abort(reason)
sets signal.aborted, stores signal.reason (a DOMException named AbortError by default, or any value passed in) and fires a one-time
abort event. signal.throwIfAborted() throws the reason if the signal is already aborted, which is how a long loop checks for
cancellation between steps.
const ac = new AbortController();
const { signal } = ac;
signal.addEventListener('abort', () => console.log('abort event, reason:', signal.reason.name), { once: true });
console.log('aborted before:', signal.aborted);
ac.abort(); // default reason is a DOMException named AbortError
console.log('aborted after:', signal.aborted, signal.reason instanceof DOMException);
const custom = new AbortController();
custom.abort(new Error('user clicked cancel'));
console.log(custom.signal.reason.message);
try {
custom.signal.throwIfAborted();
} catch (err) {
console.log('throwIfAborted threw:', err.message);
}
const timed = AbortSignal.timeout(20);
const keepAlive = setTimeout(() => {}, 1000); // the timeout signal's own timer does not keep the process alive
await new Promise((resolve) => timed.addEventListener('abort', resolve, { once: true }));
clearTimeout(keepAlive);
console.log('timeout reason:', timed.reason.name, timed.reason.code);
console.log('pre-aborted:', AbortSignal.abort().aborted);
aborted before: false
abort event, reason: AbortError
aborted after: true true
user clicked cancel
throwIfAborted threw: user clicked cancel
timeout reason: TimeoutError 23
pre-aborted: true
Three details from that run matter in practice. The default reason of a timeout signal is a TimeoutError (a DOMException with
code 23), distinct from the AbortError of a manual abort, so a handler can tell "too slow" from "cancelled". The abort
event fires at most once, so the documentation recommends checking signal.aborted before attaching a listener, and attaching
it with { once: true } to avoid leaks. And in the measured runs, a process whose only pending work was an AbortSignal.timeout() exited
without the timeout firing, so the timer behind such a signal does not keep the event loop alive by itself: if nothing else is pending, something
else must keep the process running, as the keepAlive timer does above. The documentation does not state this behaviour, so
treat it as an observation rather than a guarantee.
AbortSignal.timeout() and AbortSignal.any()
AbortSignal.timeout(ms) returns a signal that aborts itself after the delay, replacing the usual pair of setTimeout() and
clearTimeout() around an operation. AbortSignal.any(signals) returns a signal that aborts as soon as the first of several
signals does, and its reason is the reason of whichever signal fired. Combining both expresses "stop when the user cancels or
after the deadline, whichever comes first" in one value that can be passed down.
const userCancel = new AbortController();
const combined = AbortSignal.any([userCancel.signal, AbortSignal.timeout(50)]);
combined.addEventListener('abort', () => console.log('combined aborted:', combined.reason.name), { once: true });
setTimeout(() => userCancel.abort(), 10); // the user wins the race
await new Promise((resolve) => setTimeout(resolve, 80));
console.log('user reason:', combined.reason === userCancel.signal.reason);
const slow = AbortSignal.any([new AbortController().signal, AbortSignal.timeout(10)]);
const keepAlive = setTimeout(() => {}, 1000);
await new Promise((resolve) => slow.addEventListener('abort', resolve, { once: true }));
clearTimeout(keepAlive);
console.log('timeout wins otherwise:', slow.reason.name);
combined aborted: AbortError
user reason: true
timeout wins otherwise: TimeoutError
The signal option across the built-in APIs
Most asynchronous APIs that can be cancelled take a signal option, and they all follow the same contract: an already aborted
signal makes the operation fail immediately, aborting later rejects the promise (or reports the error to the callback or the
'error' event) with an AbortError whose code is ABORT_ERR, and the operation releases what it holds.
| Area | API | Documented at |
|---|---|---|
File system |
|
|
Timers |
|
|
Events |
|
|
HTTP clients |
global |
|
Child processes |
|
|
Streams |
|
The next example runs one failure from each of the five areas the issue singles out, against the Bookshelf scenario’s own file and a local server that
never answers. Note two details: events.once() rejects with a plain AbortError even when the signal came from
AbortSignal.timeout(), while fetch() rejects with the signal’s TimeoutError; and a child process stopped by the timeout option reports
killed and the signal it received (SIGTERM), whereas one stopped through a signal reports the AbortError.
import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
import { EventEmitter, once } from 'node:events';
import { createServer } from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
// AbortSignal.timeout() does not keep the event loop alive by itself, so hold it open while waiting
const keepAlive = setInterval(() => {}, 1000);
const describe = (err) => `${err.name} (${err.code})`;
// fs: an already aborted signal rejects before any I/O happens
try {
await readFile(import.meta.filename, { signal: AbortSignal.abort() });
} catch (err) {
console.log('fs.readFile:', describe(err));
}
// timers/promises
const ac = new AbortController();
const pending = sleep(10_000, undefined, { signal: ac.signal });
ac.abort();
try {
await pending;
} catch (err) {
console.log('timers sleep:', describe(err));
}
// events.once
const emitter = new EventEmitter();
try {
await once(emitter, 'ready', { signal: AbortSignal.timeout(20) });
} catch (err) {
console.log('events.once:', describe(err));
}
// fetch against a server that never answers
const server = createServer(() => { /* never respond */ });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
await fetch(`http://127.0.0.1:${server.address().port}/`, { signal: AbortSignal.timeout(50) });
} catch (err) {
console.log('fetch:', describe(err));
}
server.closeAllConnections();
server.close();
// child processes: signal and timeout both kill the child
try {
await execFileAsync(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { signal: AbortSignal.timeout(100) });
} catch (err) {
console.log('execFile signal:', describe(err));
}
try {
await execFileAsync(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { timeout: 100 });
} catch (err) {
console.log('execFile timeout:', err.killed, err.signal);
}
clearInterval(keepAlive);
fs.readFile: AbortError (ABORT_ERR)
timers sleep: AbortError (ABORT_ERR)
events.once: AbortError (ABORT_ERR)
fetch: TimeoutError (23)
execFile signal: AbortError (ABORT_ERR)
execFile timeout: true SIGTERM
Writing a cancellable operation
An operation of your own should accept { signal } as the last parameter, call signal.throwIfAborted() before starting, pass the
signal down to everything cancellable it calls, and clean up in a finally block. A request handler then builds one signal from
every reason to stop: the client disconnecting and a server-side deadline. The example does that for a search endpoint;
when the client gives up after 50 ms, the server sees the closed connection and stops sleeping through the remaining steps instead of
finishing work nobody will read.
import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
// A cancellable operation: it accepts a signal, checks it, and passes it on.
async function searchBooks(query, { signal }) {
signal.throwIfAborted();
const rows = [];
for (const title of ['Bookshelf', 'Node Streams', 'Event Loop']) {
await sleep(30, undefined, { signal }); // cancellable wait (stands in for a query)
if (title.toLowerCase().includes(query)) rows.push(title);
}
return rows;
}
const keepAlive = setInterval(() => {}, 1000); // AbortSignal.timeout() timers do not keep the process alive
const server = createServer(async (req, res) => {
const disconnect = new AbortController();
res.on('close', () => {
if (!res.writableFinished) disconnect.abort(new Error('client went away'));
});
// The work stops when the client disconnects or after 200 ms, whichever happens first.
const signal = AbortSignal.any([disconnect.signal, AbortSignal.timeout(200)]);
try {
const rows = await searchBooks('e', { signal });
res.end(JSON.stringify(rows));
} catch (err) {
if (err.name !== 'AbortError') throw err;
console.log('server: cancelled,', signal.reason.message ?? signal.reason.name);
if (!res.writableEnded) {
res.statusCode = 504;
res.end('cancelled');
}
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
console.log('fast:', await (await fetch(base, { signal: AbortSignal.timeout(1000) })).text());
// The client gives up after 50 ms, the server notices the closed connection and stops working.
try {
await fetch(base, { signal: AbortSignal.timeout(50) });
} catch (err) {
console.log('client:', err.name);
}
await sleep(150);
server.close();
clearInterval(keepAlive);
fast: ["Bookshelf","Node Streams","Event Loop"]
client: TimeoutError
server: cancelled, client went away
Two cautions apply. Listening to abort on a signal you did not create is unsafe: any other holder of the signal can call
stopImmediatePropagation() and your listener never runs, and forgetting to remove listeners leaks memory. The documented remedy is
events.addAbortListener(signal, listener), which is not affected by stopImmediatePropagation() and returns a disposable that removes
the listener, so it fits a using declaration (shown next). Second, cancellation is a request, not a guarantee: a CPU-bound loop
that never checks the signal cannot be stopped, which is another reason to keep it out of the event loop (see
Don’t Block the Event Loop).
Explicit resource management
The ECMAScript explicit resource management feature gives JavaScript a scoped-cleanup syntax. using x = value registers
value[Symbol.dispose]() to run when the enclosing block exits, whether it finishes normally, returns or throws; await using x = value
does the same with value[Symbol.asyncDispose]() and waits for the returned promise. Resources are disposed in reverse order of declaration,
like destructors, and if both the body and a disposer throw, the errors are combined in a SuppressedError. It is a language feature
supplied by V8: the syntax is accepted by Node.js 24 (V8 13.6) and 26 (V8 14.6) and is a SyntaxError on Node.js 22 (V8 12.4). The
Symbol.dispose and Symbol.asyncDispose symbols exist on all three lines, so a Node.js 22 program can still call the disposers
by hand in a try/finally.
Node.js implements the protocol on the objects that own something to release. The documented members and what they do:
| Object | Disposer calls | Added in |
|---|---|---|
|
|
v20.4.0, v18.18.0; no longer experimental v24.2.0 |
|
|
v24.1.0, v22.1.0; no longer experimental v24.2.0 |
|
|
v20.5.0 and v20.4.0; no longer experimental v24.2.0 |
Timers: |
Cancels the timer, like |
v20.5.0, v18.18.0; no longer experimental v24.2.0 |
|
|
v20.5.0, v18.18.0; no longer experimental v24.2.0 |
|
|
v20.4.0 and v22.4.0; no longer experimental v24.2.0 |
|
Removes the abort listener ( |
v20.5.0; stable v24.0.0 and v22.16.0 |
|
Restores the previous store ( |
v25.9.0 (Experimental) |
import { open } from 'node:fs/promises';
import { createServer } from 'node:http';
import { addAbortListener } from 'node:events';
class Lease {
constructor(name) { this.name = name; console.log('acquire', name); }
[Symbol.dispose]() { console.log('release', this.name); }
}
class Connection {
constructor(name) { this.name = name; console.log('open', name); }
async [Symbol.asyncDispose]() {
await Promise.resolve(); // e.g. flush and close a socket
console.log('close', this.name);
}
}
function sync() {
using a = new Lease('a');
using b = new Lease('b');
console.log('working with', a.name, b.name);
throw new Error('boom'); // both leases are still released, in reverse order
}
try { sync(); } catch (err) { console.log('caught', err.message); }
async function asyncWork() {
await using conn = new Connection('db');
await using file = await open(import.meta.filename); // FileHandle has Symbol.asyncDispose
const { size } = await file.stat();
console.log('file bytes > 0:', size > 0);
await using server = createServer(); // http.Server too: calls server.close()
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
console.log('listening:', server.listening);
}
await asyncWork();
console.log('done');
// events.addAbortListener returns a Disposable that removes the listener
const ac = new AbortController();
{
using _ = addAbortListener(ac.signal, () => console.log('never printed'));
}
ac.abort();
console.log('typeof Symbol.dispose:', typeof Symbol.dispose, typeof Symbol.asyncDispose);
sync() threw)acquire a
acquire b
working with a b
release b
release a
caught boom
open db
file bytes > 0: true
listening: true
close db
done
typeof Symbol.dispose: symbol symbol
Disposing a ChildProcess sends SIGTERM and cancelling a timer is a disposal too, so short-lived work can be scoped without
bookkeeping. The DisposableStack and AsyncDisposableStack globals gather an unknown number of cleanups (use(), adopt(), defer())
into one disposable, and SuppressedError reports a failure in both the body and a disposer.
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
{
using child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']);
child.on('exit', (code, sig) => console.log('child exit:', code, sig));
await sleep(100);
console.log('leaving block, killed =', child.killed);
}
await sleep(200);
{
using t = setTimeout(() => console.log('timer fired'), 50); // Timeout has Symbol.dispose, cancels it
}
await sleep(100);
console.log('no timer fired');
leaving block, killed = false
child exit: null SIGTERM
no timer fired
Source: Node.js API: Child process (subprocess[Symbol.dispose())], Node.js API: Timers (timeout[Symbol.dispose())]
console.log(typeof DisposableStack, typeof AsyncDisposableStack, typeof SuppressedError);
if (typeof AsyncDisposableStack === 'function') {
await using stack = new AsyncDisposableStack();
stack.defer(() => console.log('deferred cleanup 1'));
stack.adopt({ id: 7 }, (res) => console.log('adopted cleanup for', res.id));
stack.use({ [Symbol.dispose]: () => console.log('used disposable') });
console.log('body');
}
const sync = {
[Symbol.dispose]() { throw new Error('dispose failed'); },
};
try {
using _ = sync;
throw new Error('body failed');
} catch (err) {
console.log(err.constructor.name, '|', err.error.message, '|', err.suppressed.message);
}
function function function
body
used disposable
adopted cleanup for 7
deferred cleanup 1
SuppressedError | dispose failed | body failed
Source: TC39: Explicit Resource Management (DisposableStack, AsyncDisposableStack, SuppressedError), MDN: DisposableStack
On Node.js 22 the same disposers are available as ordinary methods, so the equivalent of await using is a try/finally.
import { open } from 'node:fs/promises';
import { createServer } from 'node:http';
console.log(typeof Symbol.dispose, typeof Symbol.asyncDispose, typeof DisposableStack);
const file = await open(import.meta.filename);
console.log('FileHandle async-disposable:', typeof file[Symbol.asyncDispose]);
try {
console.log('bytes > 0:', (await file.stat()).size > 0);
} finally {
await file[Symbol.asyncDispose](); // what `await using` calls for you
}
const server = createServer();
console.log('Server async-disposable:', typeof server[Symbol.asyncDispose]);
DisposableStack is undefined there; on 26 and 24 it prints function)symbol symbol undefined
FileHandle async-disposable: function
bytes > 0: true
Server async-disposable: function
DEP0209: do not use AbortSignal to dispose of resources
DEP0209 is a documentation-only deprecation (introduced in v26.8.2): it generates no runtime warning and changes no behaviour. It
says that using an AbortSignal to destroy long-lived resources is deprecated and that using should be preferred for cleanup,
while AbortSignal remains a good fit for cancelling actions, propagating cancellation from the outside and timeouts. The
distinction is ownership. A signal says "stop this operation" and is handed to code that does not own the resource. A disposer lives with the
code that created the resource, in the scope that created it. The documentation lists three patterns as deprecated: passing a signal to
server.listen() and aborting it to close the server, wrapping a stream with addAbortSignal() and aborting it after use, and passing
a signal to spawn() to kill a child at the end of the work. The child_process pages repeat the rule for the signal option. Each
has a using replacement: await using server = http.createServer(handler), await using stream = fs.createReadStream(file) and
using child = spawn(command, args).
using syntax is a SyntaxError on Node.js 22)import { createServer } from 'node:http';
import { once } from 'node:events';
// Deprecated (documentation-only, DEP0209): an AbortSignal used to tear a long-lived resource down.
async function before() {
const ac = new AbortController();
const server = createServer();
server.listen({ port: 0, host: '127.0.0.1', signal: ac.signal });
await once(server, 'listening');
console.log('before: listening', server.listening);
ac.abort();
await once(server, 'close');
console.log('before: closed');
}
// Preferred: the owner of the resource disposes of it with `using`.
async function after() {
await using server = createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
console.log('after: listening', server.listening);
} // server.close() is awaited here
await before();
await after();
before: listening true
before: closed
after: listening true
Both halves of the example work on Node.js 26 today, which is what documentation-only means. Codebases that need Node.js 22 cannot use
using yet, so for them the practical rule is to keep calling close() or destroy() in a finally block and to use signals only for
cancellation and deadlines. The timeouts and cancellation shown earlier (AbortSignal.timeout() on fetch(), execFile() and
once()) are not affected by the deprecation.
What changed recently
| Version | Change |
|---|---|
v16.4.0 |
|
v20.3.0, v18.17.0 |
|
v22.7.0 |
|
v22.15.0, v23.11.0 |
|
v22.16.0, v24.0.0 |
|
v24.0.0 |
|
v24.2.0 |
The |
v25.9.0 (v24.20.0) |
|
v26.1.0 |
|
v26.8.2 |
DEP0209 documentation-only deprecation: do not use |
Where the books are outdated
Efficient Node.js (Samer Buna, O’Reilly 2025) and Node.js Projects (Jonathan Wexler, O’Reilly 2025) are the references consulted for
this section. Neither contains a treatment of AsyncLocalStorage, of AbortController and AbortSignal, or of explicit resource
management, so a reader who learns request context from them has no current approach to follow: the pattern here is
run() at the edge and getStore() below it, on the AsyncContextFrame implementation that has been the default since Node.js 24, with
async_hooks treated as a legacy debugging API. The same gap applies to cancellation: current code threads an AbortSignal through the signal option, uses AbortSignal.timeout()
for deadlines and AbortSignal.any() to combine reasons. For cleanup, using and await using on Node.js 24 and 26 release
files, servers and child processes at the end of a block, and the DEP0209 deprecation (v26.8.2) discourages the signal-based
teardown that older articles show.
References
-
Node.js API: Asynchronous context tracking, https://nodejs.org/docs/latest/api/async_context.html
-
Node.js API: Async hooks, https://nodejs.org/docs/latest/api/async_hooks.html
-
Node.js API: Globals (AbortController, AbortSignal, fetch), https://nodejs.org/docs/latest/api/globals.html
-
Node.js API: Deprecated APIs (DEP0209), https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Events, https://nodejs.org/docs/latest/api/events.html
-
Node.js API: Timers, https://nodejs.org/docs/latest/api/timers.html
-
Node.js API: File system, https://nodejs.org/docs/latest/api/fs.html
-
Node.js API: Child process, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: Stream, https://nodejs.org/docs/latest/api/stream.html
-
Node.js API: Utilities (
util.aborted()), https://nodejs.org/docs/latest/api/util.html -
TC39: Explicit Resource Management, https://tc39.es/proposal-explicit-resource-management/
-
MDN: DisposableStack, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DisposableStack
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/