· 6 min read
JavaScript using: automatic resource cleanup with Symbol.dispose
JavaScript's using declaration runs cleanup automatically when a scope ends. How Symbol.dispose, await using and DisposableStack work, and where it runs.

JavaScript's using declaration ties cleanup to scope: when the block that declares a resource ends, the runtime calls that resource's [Symbol.dispose]() method for you, whether the block finished normally, returned early or threw. It is part of the explicit resource management proposal, and it already runs in Chrome and Edge 134, Firefox 141, Node.js 24, Deno and Bun. Safari has it only in Technology Preview so far.
If you have ever written a try/finally just to close a file handle, release a lock or end a span, this is the language-level version of that pattern.
What is the JavaScript using declaration?
using declares a block-scoped constant, like const, with one extra rule. The value must be an object with a [Symbol.dispose]() method, or null, or undefined. When the enclosing scope exits, the runtime calls that method.
A disposable is any object that has a [Symbol.dispose]() method. Symbol.dispose is a new well-known symbol, the same kind of hook as Symbol.iterator.
class Connection {
constructor(name) { this.name = name; console.log('open', name); }
[Symbol.dispose]() { console.log('close', this.name); }
}
{
using a = new Connection('a');
using b = new Connection('b');
console.log('working');
}
// open a
// open b
// working
// close b
// close a
Two details matter here. Resources are disposed in reverse order of declaration, so b closes before a. That is the order you want when one resource depends on another, such as a transaction opened on a connection. And disposal happens at the closing brace, not whenever the garbage collector gets around to it.
Why not just use try/finally?
You can, and the result is equivalent. The problem is how it scales. One resource needs one try/finally. Three resources need three nested blocks, or one block with careful null checks so a failure while opening the second resource does not try to close something that was never opened.
// The old way
const conn = await db.connect();
try {
const tx = await conn.begin();
try {
await tx.query('UPDATE accounts SET balance = balance - 10 WHERE id = 1');
await tx.commit();
} finally {
await tx.release();
}
} finally {
await conn.close();
}
With using, the acquire and the cleanup sit on the same line, and adding a resource does not add a level of indentation. The library author writes the cleanup once, inside the disposer, and every caller gets it right by default.
There is also an early-return benefit. A function that returns from the middle of a block still disposes everything declared above the return, and the return value is computed before cleanup runs.
function readHeader() {
using file = openSync('data.bin');
return file.read(16); // read happens, then file is disposed
}
How does await using work?
Some cleanup is asynchronous: flushing a buffer, closing a network socket, committing a transaction. For those, the protocol uses Symbol.asyncDispose, and you declare the resource with await using inside an async function or at module top level.
async function handle() {
await using conn = await pool.connect();
await conn.query('SELECT 1');
} // runtime awaits conn[Symbol.asyncDispose]() here
Node.js 24 already implements this on some built-ins. A FileHandle from fs/promises has a [Symbol.asyncDispose]() method, so this closes the handle when the function exits:
import { open } from 'node:fs/promises';
async function firstLine(path) {
await using fh = await open(path);
for await (const line of fh.readLines()) return line;
}
await using also accepts plain synchronous disposables. If an object has only [Symbol.dispose](), the runtime calls that instead.
What happens when cleanup throws?
This is the part try/finally handles badly. If the block throws and then a finally clause throws too, the second error replaces the first, and the original cause is lost.
using keeps both. When disposal throws while another error is already in flight, the runtime wraps them in a SuppressedError, a new error type with two properties: error, the newer error from disposal, and suppressed, the one it would otherwise have hidden.
try {
using r = { [Symbol.dispose]() { throw new Error('dispose failed'); } };
throw new Error('body failed');
} catch (e) {
console.log(e.name); // SuppressedError
console.log(e.error.message); // dispose failed
console.log(e.suppressed.message); // body failed
}
If your error reporting only logs e.message, update it to walk suppressed as well, or you will see the cleanup failure and miss the real cause.
What are DisposableStack and the rules to know?
Not every resource is a class you control. DisposableStack is a built-in container that collects cleanup work and runs it in reverse order when it is disposed. It has three ways to add things:
| Method | Use it for |
|---|---|
stack.use(value) | An object that already has [Symbol.dispose]() |
stack.adopt(value, fn) | A value with no disposer; fn(value) cleans it up |
stack.defer(fn) | A bare callback to run at disposal |
function setup() {
using stack = new DisposableStack();
const timer = stack.adopt(setInterval(poll, 1000), clearInterval);
stack.defer(() => console.log('setup undone'));
const cache = stack.use(openCache());
// Everything succeeded: hand ownership to the caller.
return { timer, cache, cleanup: stack.move() };
}
move() is the useful trick. It transfers every registered cleanup into a fresh stack and marks the original as disposed, so the using at the top does nothing on a normal return. If anything throws halfway through setup, the original stack still unwinds what was already acquired. AsyncDisposableStack is the async counterpart.
A few rules catch people out:
- Not at the top level of a classic script.
usingworks in blocks, functions, module top level,for,for...ofandfor await...ofheads, but not at the top level of a non-module script, because that scope never really ends. - No destructuring.
using { a, b } = thingis a syntax error. Bind the whole object. - A missing disposer throws immediately.
using x = {}throws aTypeErrorat the declaration, not at the end of the block. - null and undefined are allowed. That makes optional resources easy:
using maybe = flag ? acquire() : null. - In a loop head, each iteration disposes its own value.
for (using item of items)closes each item before the next one starts.
For support, check your targets. Node 22 defines Symbol.dispose but fails to parse using, so you need Node 24 or later. TypeScript has compiled using down for older runtimes since version 5.2; add esnext.disposable (or esnext) to lib so the types for Disposable and DisposableStack are available. Like the Temporal API, this is a language feature you can adopt on the server today while browser coverage finishes.
Key takeaways
usingcalls[Symbol.dispose]()automatically when the enclosing block exits, on every path.- Resources are disposed in reverse order, which matches how dependent resources should be released.
- Use
await usingwithSymbol.asyncDisposefor async cleanup such as closing file handles or connections. - A failing disposer produces a
SuppressedErrorthat keeps both errors instead of losing one. DisposableStackhandles values you do not control, andmove()hands cleanup to the caller once setup succeeds.
FAQ
Can I use the using keyword in the browser today?
In Chrome, Edge and Firefox, yes. Safari only has it in Technology Preview, so for public sites either transpile with TypeScript or keep using to server code for now.
Does using replace try/finally?
For resource cleanup, mostly. You still need try/catch to handle errors, and finally remains the right tool for cleanup that is not tied to a specific value.
How do I make my own class work with using?
Add a [Symbol.dispose]() method that releases whatever the instance holds. For async cleanup, add async [Symbol.asyncDispose]() instead, and make both safe to call more than once.