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

Table 1. APIs covered on this page, with their stability and the Node.js lines that have them
API Stability (Node.js 26 docs) Version notes

AsyncLocalStorage (run(), getStore())

2 Stable

Added in v13.10.0 and v12.17.0; Stable since v16.4.0.

AsyncLocalStorage.bind(), AsyncLocalStorage.snapshot()

Stable

Added in v19.8.0 and v18.16.0; marked stable in v23.11.0 and v22.15.0.

new AsyncLocalStorage({ name, defaultValue })

Stable

Options added in v24.0.0; not available on Node.js 22.

enterWith(), exit(), disable()

1 Experimental

Present on every line; avoid enterWith() unless there is a strong reason.

withScope() and RunScope

1 Experimental

Added in v25.9.0 and backported to v24.20.0; absent on Node.js 22.

AsyncLocalStorage backed by AsyncContextFrame

Default implementation

Default since v24.0.0 (opt out with --no-async-context-frame); experimental opt-in with --experimental-async-context-frame since v22.7.0.

AsyncResource

2 Stable

Stable since v16.4.0; AsyncResource.bind() added in v14.8.0 and v12.19.0.

createHook(), executionAsyncId(), triggerAsyncId()

1 Experimental, migrate away

The whole node:async_hooks hook API page carries that note.

AbortController, AbortSignal

Stable

Added in v15.0.0 and v14.17.0; reason since v17.2.0 and v16.14.0.

AbortSignal.timeout()

Stable

Added in v17.3.0 and v16.14.0.

AbortSignal.any()

Stable

Added in v20.3.0 and v18.17.0.

events.addAbortListener(), util.aborted()

Stable

Became stable in v24.0.0 and v22.16.0.

using, await using, Symbol.dispose, Symbol.asyncDispose

JavaScript language feature (V8)

Syntax works on Node.js 24 and 26 (V8 13.6 and 14.6); a SyntaxError on Node.js 22 (V8 12.4). The Node.js objects that implement the protocol are no longer experimental since v24.2.0.

DisposableStack, AsyncDisposableStack, SuppressedError

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 AbortSignal to dispose of long-lived resources is deprecated, prefer 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.

Request context and log correlation with AsyncLocalStorage
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();
Output, identical on Node.js 26, 24 and 22 (each request keeps its own id across the awaits)
{"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

Table 2. The AsyncLocalStorage methods and what they do
Member Behaviour

run(store, callback, …​args)

Runs callback synchronously with store as the current store and returns its result. The store is visible to the asynchronous work started inside the callback and not outside it. If the callback throws, the error propagates from run() and the context is exited.

getStore()

Returns the current store, or undefined outside any run() (or enterWith()) context.

exit(callback, …​args)

Runs a callback with no store (Experimental).

enterWith(store)

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 run().

disable()

Disables the instance until the next run() or enterWith(); the documentation says this is required before an instance can be garbage collected (Experimental).

name, defaultValue (constructor options)

A name for the instance and the value getStore() falls back to; added in v24.0.0.

AsyncLocalStorage.bind(fn)

Returns a function that always runs fn in the context captured when bind() was called.

AsyncLocalStorage.snapshot()

Returns a function that runs any function it is given inside the context captured at the time of the call.

Nested runs, bind() and snapshot() (run on Node.js 26 and 24; on Node.js 22 the 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);
Output on Node.js 26 (the three asynchronous lines arrive last)
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.

withScope() restores the previous store at the end of the block, enterWith() does not (Node.js 26 and 24)
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());
Output
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.

Observing the implementation difference (Node.js 26.10.0 and 24.21.0)
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());
Output with the default implementation and with the legacy flag
$ 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.

Legacy hooks observing resources, and binding queued callbacks so the request context survives
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);
Output on Node.js 26, 24 and 22 (the id printed by 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

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.

Creating, aborting and inspecting signals (identical on Node.js 26, 24 and 22)
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);
Output
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.

AbortSignal.any() combining a user cancellation with a deadline (Node.js 26, 24, 22)
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);
Output
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.

Table 3. Where the signal option is accepted (a selection, with the page that documents it)
Area API Documented at

File system

fs.readFile(), fs.writeFile(), fs/promises equivalents, filehandle.readFile(), filehandle.writeFile(), fs.watch() and filehandle.stat() (since v26.1.0)

fs

Timers

timers/promises setTimeout(), setImmediate(), setInterval() (the async iterator) and scheduler.wait() (Experimental)

timers

Events

events.once() and events.on(), events.addAbortListener(), util.aborted()

events

HTTP clients

global fetch() (rejects with the signal’s reason, TimeoutError for AbortSignal.timeout()); http.request() and https.request()

globals

Child processes

spawn(), exec(), execFile() and fork() accept signal and timeout; the error code is ABORT_ERR when aborted through the signal

child_process

Streams

stream.addAbortSignal(), the signal option of stream/promises pipeline() and finished(), and of readline interfaces

stream

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.

One cancellation per area: fs, timers, events, fetch and child processes (Node.js 26, 24 and 22 print the same lines)
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);
Output
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.

A cancellable service call, bounded by a client disconnect and a deadline (Node.js 26, 24 and 22)
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);
Output
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:

Table 4. Built-in objects that can be used with using and await using
Object Disposer calls Added in

FileHandle from fs/promises

filehandle.close() (Symbol.asyncDispose)

v20.4.0, v18.18.0; no longer experimental v24.2.0

fs.Dir

dir.close() or dir.closeSync() (both symbols)

v24.1.0, v22.1.0; no longer experimental v24.2.0

net.Server and http.Server

server.close(), awaiting the close (Symbol.asyncDispose)

v20.5.0 and v20.4.0; no longer experimental v24.2.0

Timers: Timeout and Immediate

Cancels the timer, like clearTimeout() or clearImmediate() (Symbol.dispose)

v20.5.0, v18.18.0; no longer experimental v24.2.0

ChildProcess

subprocess.kill('SIGTERM') (Symbol.dispose)

v20.5.0, v18.18.0; no longer experimental v24.2.0

stream.Readable and stream.Writable

destroy() with an AbortError, resolving when the stream has finished (Symbol.asyncDispose)

v20.4.0 and v22.4.0; no longer experimental v24.2.0

events.addAbortListener() result

Removes the abort listener (Symbol.dispose)

v20.5.0; stable v24.0.0 and v22.16.0

asyncLocalStorage.withScope() result

Restores the previous store (Symbol.dispose)

v25.9.0 (Experimental)

using and await using with your own classes and with Node.js objects (Node.js 26 and 24)
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);
Output (both disposables are released in reverse order even though 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.

Spawned process and timer scoped by using; stacks and SuppressedError (Node.js 26 and 24)
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');
Output
leaving block, killed = false
child exit: null SIGTERM
no timer fired
DisposableStack, AsyncDisposableStack and SuppressedError
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);
}
Output
function function function
body
used disposable
adopted cleanup for 7
deferred cleanup 1
SuppressedError | dispose failed | body failed

On Node.js 22 the same disposers are available as ordinary methods, so the equivalent of await using is a try/finally.

The Node.js 22 equivalent: call the disposer yourself (runs on 26, 24 and 22)
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]);
Output on Node.js 22 (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).

The deprecated pattern next to the replacement (Node.js 26 and 24; the 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();
Output
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

Table 5. Changes relevant to async context, cancellation and disposal, with the versions that introduced them
Version Change

v16.4.0

AsyncLocalStorage and AsyncResource became Stable.

v20.3.0, v18.17.0

AbortSignal.any() added.

v22.7.0

--experimental-async-context-frame added, so Node.js 22 can try the AsyncContextFrame implementation.

v22.15.0, v23.11.0

AsyncLocalStorage.bind() and AsyncLocalStorage.snapshot() marked stable.

v22.16.0, v24.0.0

events.addAbortListener() and util.aborted() changed from Experimental to Stable.

v24.0.0

AsyncLocalStorage backed by AsyncContextFrame by default (opt out with --no-async-context-frame); name and defaultValue constructor options added.

v24.2.0

The Symbol.dispose and Symbol.asyncDispose methods on Node.js objects are no longer experimental.

v25.9.0 (v24.20.0)

asyncLocalStorage.withScope() and RunScope added, Experimental.

v26.1.0

filehandle.stat() accepts a signal option.

v26.8.2

DEP0209 documentation-only deprecation: do not use AbortSignal to dispose of long-lived resources.

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.