How it works
A block that contains a using declaration keeps a list of DisposableResource Records, each with the value, the kind (sync-dispose or async-dispose), and the dispose method. The method is read when the declaration runs, through GetDisposeMethod: for await using it prefers Symbol.asyncDispose and falls back to Symbol.dispose, wrapping it so a sync throw becomes a rejection.
When the block's statements finish, Block evaluation passes the block's completion and the list to DisposeResources. That operation walks the list in reverse and calls each method with the resource as this. Each call can end the block with a new completion:
- If the disposer returns normally, the earlier completion stands.
- If it throws and the current outcome is normal, the throw becomes the outcome.
- If it throws and the outcome is already a throw, the spec creates a
SuppressedError with error = the new error and suppressed = the old one, and that becomes the outcome.
The spec creates this SuppressedError without a message, so any default message text is engine-specific. Nesting happens naturally: with two failing disposers and a failing body, the body error is suppressed by the second disposer's error, which is then suppressed by the first's:
const failing = (n) => ({ [Symbol.dispose]() { throw new Error(`dispose ${n}`); } });
try {
using r1 = failing(1);
using r2 = failing(2);
throw new Error('body');
} catch (error) {
console.log(error.name, error.error.message);
// → SuppressedError dispose 1
console.log(error.suppressed.error.message, error.suppressed.suppressed.message);
// → dispose 2 body
}
Edge cases
await using awaits each async disposer before starting the next. Even a null value costs one await at block exit. A resource that has only Symbol.dispose is also accepted: the engine wraps its method so a sync throw becomes a rejection.
const asyncResource = (name) => ({
async [Symbol.asyncDispose]() {
await null;
console.log('async close', name);
},
});
async function f() {
await using a = asyncResource('a');
await using b = asyncResource('b');
console.log('body');
}
await f();
// → body
// → async close b
// → async close a
The disposal list is per block, so a using inside a loop body runs at the end of each iteration. for (using x of xs) is the same idea. Node.js already implements the protocol on its own objects: fs.promises FileHandle has Symbol.asyncDispose (it calls close()), so await using file = await fs.open(path) works in Node 24.
Real-world scenario
A constructor acquires two resources. If the second step throws, the first must be released, but if everything works, ownership moves to the new object. A DisposableStack expresses both:
class Connection {
#cleanup;
constructor(failAfterSocket) {
using stack = new DisposableStack();
stack.use(open('socket'));
if (failAfterSocket) throw new Error('handshake failed');
stack.use(open('session'));
this.#cleanup = stack.move(); // keeps the resources alive past this block
}
[Symbol.dispose]() {
this.#cleanup.dispose();
}
}
try {
new Connection(true);
} catch (error) {
console.log(error.message);
}
// → open socket
// → close socket
// → handshake failed
{
using connection = new Connection(false);
console.log('connected');
}
// → open socket
// → open session
// → connected
// → close session
// → close socket
(open is the helper from the main example.) move() returns a new stack holding the resources and marks the old one disposed, so the using stack at the end of the constructor has nothing left to dispose.
Trade-offs
Compared to try/finally, using is flatter, orders cleanup for you, and keeps both errors. The cost is availability: tooling that does not know the syntax cannot parse the file, so check your bundler, linter, and runtimes before adopting it. The Symbol.dispose and DisposableStack APIs can be used earlier than the syntax, with try/finally calling stack.dispose(). Where you only need one cleanup in one function, plain try/finally is still fine.
Disposal is explicit and scope-based. It does not replace garbage collection, and FinalizationRegistry is not a substitute, because whether and when its callbacks run is up to the engine.