Every JavaScript resource you open needs cleanup. Every cleanup is a try/finally. Every try/finally is five lines of boilerplate wrapping the one line that does the actual work. You’ve written this thousands of times. You still occasionally forget the await in the finally block and ship a leak.
The using keyword deletes most of it. It’s not a proposal anymore — Chrome 134 shipped it in March 2025, Firefox 141 followed, Node.js 24 has it natively. The reason you might still be writing the old code is that the official docs read like a spec, and every tutorial is either a 2023 TypeScript guide or uses FakeDatabaseWriter examples that don’t map to anything in your codebase.
Three patterns. Real APIs. The one async gotcha that will quietly leak resources in production.
What using Actually Does (90-Second Version)
using x = something() binds x to a value and registers its Symbol.dispose method to run when the enclosing block exits. Hit a return, a throw, fall off the end of the function — disposal fires either way. That’s the part try/finally was always doing by hand.
await using x = something() does the same thing for Symbol.asyncDispose — the version where cleanup itself returns a promise. A database connection that needs to flush a transaction. A file handle that buffers writes. A WebSocket that needs to send a close frame.
Multiple declarations dispose in reverse declaration order — LIFO, like a stack. The last resource opened is the first one closed. This holds even if an exception throws mid-block. That’s the whole point.
Support reality in one line: Node.js 24+, Chrome 134+ (March 2025), Firefox 141+, Bun 1.0.23+, Deno 1.37+ all ship it natively. Safari has nothing — WebKit bug 248707 is still open with no date. Polyfill via core-js if you target current Safari users.
Skip DisposableStack internals and SuppressedError for now. We hit those when the patterns need them.
Pattern 1: File Handles Without the try/finally Dance
The simplest case ships built-in. Node.js fs/promises FileHandle already implements Symbol.asyncDispose — has done since Node 20.4. You don’t wrap or adapt anything.
Before — the version you’ve written a thousand times:
import { open } from 'node:fs/promises';
async function readConfig(path) {
const handle = await open(path, 'r');
try {
const data = await handle.readFile({ encoding: 'utf8' });
return JSON.parse(data);
} finally {
await handle.close();
}
}
Eleven lines. The try/finally exists so a throw inside readFile or JSON.parse doesn’t leak the descriptor. Most of the code is plumbing.
After:
import { open } from 'node:fs/promises';
async function readConfig(path) {
await using handle = await open(path, 'r');
const data = await handle.readFile({ encoding: 'utf8' });
return JSON.parse(data);
}
Five lines. No finally. A throw in readFile still closes the handle on the way out. A successful return still closes the handle on the way out. Disposal is implicit in the scope.
That’s a 55% reduction on a function that opens one file. Multiply by every file open in a codebase and the savings compound.
Why await using and not plain using? Because handle[Symbol.asyncDispose]() returns a promise — closing a file is itself async. Use plain using and the close becomes fire-and-forget. The function returns before the file finishes flushing, which can corrupt buffered writes. That’s the gotcha section, and we’ll get there.
Pattern 2: Database Connections With DisposableStack
File handles are easy because the API ships Symbol.asyncDispose. Most database libraries don’t yet — pg, mysql2, redis still want you to call .release() or .end() manually. DisposableStack is the adapter pattern that bridges them.
Before — pool checkout with transaction handling:
async function transferFunds(from, to, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Sixteen lines. Three exit paths — success, app error, network error — each needs client.release() to fire. Forget it on any branch and the pool slowly starves until your app stops responding.
After:
async function transferFunds(from, to, amount) {
await using stack = new AsyncDisposableStack();
const client = stack.adopt(await pool.connect(), c => c.release());
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
}
}
The stack.adopt() call wraps the client with a release callback. When stack goes out of scope, every adopted resource cleans up in reverse order. The try/catch stays because rollback is application logic, not resource cleanup. Those are different responsibilities, and conflating them is what made the old version painful in the first place.
stack.defer() is the sibling for cleanup with no associated resource (close a span, flush a metric). stack.move() transfers ownership — you use it to keep a resource alive past the current scope. That’s the case where try/finally still wins, and we get to that at the end.
Files and databases cover the server. The front-end leak champion still hasn’t been touched.
Pattern 3: Event Listeners Without the addEventListener / removeEventListener Dance
If you’ve ever debugged a React component that leaks memory across renders, the culprit was almost certainly an event listener that never got removed. The pattern everyone writes:
const handler = (e) => { /* do thing */ };
button.addEventListener('click', handler);
// ... later, somewhere else ...
button.removeEventListener('click', handler);
Reference equality required. Lose the handler reference and the listener leaks forever. Inline an arrow function and you can’t remove it at all. Half of every useEffect cleanup exists for this.
A six-line helper kills the entire pattern:
function listen(target, event, handler) {
const ac = new AbortController();
target.addEventListener(event, handler, { signal: ac.signal });
return { [Symbol.dispose]() { ac.abort(); } };
}
AbortController does the actual work — it’s been the right way to remove listeners since 2022. The wrapper just attaches Symbol.dispose so using can reach it.
Now the consumer code:
function attachUI(button, input) {
using clickSub = listen(button, 'click', onClick);
using inputSub = listen(input, 'input', onInput);
using keySub = listen(document, 'keydown', onKey);
// ... wire these up to whatever component logic ...
}
Three listeners, one cleanup block, zero removeEventListener calls. When attachUI returns — successfully, via throw, via early return — all three abort controllers fire in reverse order. No array to track. No cleanup function to remember.
This drops directly into useEffect, onMount, or any framework’s setup hook. The lifecycle scope and the using scope are the same thing. Cleanup logic deletions: 100%. It’s now implicit in the language. The same disposal pattern applies beyond event listeners — you can terminate workers on unmount with the same scope-bound guarantee, no manual cleanup function required.
The math from a real component: a three-listener setup goes from roughly 14 lines to about 6. Across a review, the “did you remember to remove that listener?” comments stop happening entirely.
Three patterns down. That await using keyword has been quietly carrying a footgun the whole time.
The Async Disposal Gotcha That Will Bite You
There are three failure modes nobody else writes about. They all look identical to working code until production tells you otherwise.
Forgetting await. Write using client = await pool.connect() — plain using on an async resource — and the runtime doesn’t error. The await on pool.connect() is the one you wrote. But Symbol.dispose doesn’t exist on the client object; only Symbol.asyncDispose does. Disposal silently no-ops. The connection leaks. Logs look clean. Performance degrades over hours. The code that passed dev fails under load three weeks later.
Mixed disposal order. Stack using a = ...; await using b = ...; using c = ...; and disposal runs c → b → a. But b’s disposal is async — the function suspends there. If a’s cleanup needed to run first for correctness (say, a is a lock that b depends on), you’ve reordered the dependency chain. The bug surfaces only when b takes long enough for another caller to grab the lock.
SuppressedError. If your function throws and disposal also throws, the runtime wraps both in a SuppressedError — err.error is the original, err.suppressed is the disposal failure. Without using, the disposal error would have replaced the original. Better behavior, but your existing logging probably only reports err.message. The original cause hides one property deep until you teach your logger about it.
The rule that prevents all three: if a resource’s cleanup returns a promise, you must use await using. JavaScript can’t warn you — Symbol.dispose is just a method name; the runtime can’t introspect whether your implementation returns a promise. ESLint plugins are starting to land for this. Until they’re solid, code review is the only guardrail.
Patterns work. Gotchas accounted for. The question left: can you actually ship this?
Can You Ship It Today? (And When to Stick With try/finally)
Support breakdown:
| Runtime | Status |
|---|---|
| Node.js 24+ | Native |
| Chrome 134+ (Mar 2025) | Native |
| Firefox 141+ | Native |
| Bun 1.0.23+ | Native |
| Deno 1.37+ | Native |
| Safari | Not shipped (WebKit bug 248707) |
Server code on Node, Bun, or Deno — yes, today, no polyfill. Browser code targeting modern Chromium and Firefox — yes. Browser code that has to run on current Safari — polyfill Symbol.dispose, Symbol.asyncDispose, DisposableStack, AsyncDisposableStack, and SuppressedError via core-js, or stick with try/finally for now. The polyfill adds about 2KB gzipped and works fine.
Three cases where try/finally still wins. Conditional disposal — DisposableStack.move() exists for ownership transfer, but a try/finally with a released flag is clearer when you only sometimes want to release. Partial scope — you need to dispose mid-block and keep working with other resources. using is all-or-nothing per block. Public libraries targeting Node 20 or older — they still need the old pattern as a fallback.
For everything else, await using for async cleanup, plain using for sync. Like the Temporal API, decorators, and the TC39 Signals proposal, this is one of the rare additions that’s pure subtraction.
Where to Start
Started with the same observation every senior dev shares: try/finally is boilerplate nobody enjoys. Three patterns later, most of it is gone — file handles drop to half their original line count, database connections lose the “did you remember to release?” review comment forever, event listeners stop leaking because the cleanup is now implicit in the scope.
Concrete first move: open the file in your codebase with the most try { ... } finally { await x.close(); } blocks. Replace them with await using one at a time. Each diff is small, reviewable, and shrinks the file. Run your tests. Ship. Move to the next file.
If you only remember one thing, remember this: await using for cleanup that returns a promise, plain using for cleanup that doesn’t. The bug from getting it wrong won’t throw and won’t log. It just leaks.
This is one of the rare language additions that subtracts. Fewer lines. Fewer ways to forget cleanup. Fewer try/finally blocks to maintain. Ship it where you can.