A hands-on tour of ES2026 explicit resource management: using, await using, and AsyncDisposableStack, taught by building a leak-proof connection pool in TypeScript.
The JavaScript using keyword is now part of the official language: Ecma's General Assembly ratified ES2026 at the end of June, and explicit resource management is the headline feature. Short answer for anyone landing here from a search: using x = resource binds a value and guarantees its [Symbol.dispose]() method runs when the enclosing scope exits, on every path, including throws and early returns. await using does the same for async cleanup via [Symbol.asyncDispose](). It is try/finally you cannot forget to write.
The spec stamp is a formality at this point. V8 shipped it in Chrome 134 back in March 2025, Node has run it unflagged since Node 24, Firefox and Safari followed over the past year, and TypeScript has been able to transpile it down to older targets since 5.2. If you run Node 24 or newer, you can use everything in this tutorial today with zero build config, because Node executes .ts files directly by stripping types.
Why care, in cost terms? Because leaked resources are one of the quietest ways to burn money. Postgres ships with max_connections set to 100 and each open connection pins several megabytes of server memory; a handler that skips release() on one error path will exhaust that in minutes under load, which is a big part of why people end up paying for PgBouncer or RDS Proxy. A default Linux box gives your process 1,024 file descriptors before EMFILE starts killing requests. And garbage collection will not save you: FinalizationRegistry explicitly does not guarantee cleanup callbacks ever run. Deterministic disposal is the fix, and now it is native syntax.
To see every piece of the feature working, we will build a miniature database connection pool in one file, pool.ts. It starts as the classic leaky code you have definitely reviewed, then picks up using, await using, LIFO disposal ordering, disposable leases, and finally AsyncDisposableStack for whole-pool shutdown. Run each step with node pool.ts and watch the live counter: the whole point of the exercise is driving leaked connections to zero.
// pool.ts - run: node pool.ts (Node 24+)
let live = 0;
function log(msg: string) {
console.log(`${msg} (live: ${live})`);
}
class Conn {
static count = 0;
id = ++Conn.count;
constructor() {
live++;
log(`open #${this.id}`);
}
query(sql: string) {
return `rows for "${sql}" via #${this.id}`;
}
end() {
live--;
log(`close #${this.id}`);
}
}
function report(day: number) {
const conn = new Conn();
const rows = conn.query(`sales for day ${day}`);
if (day === 3) throw new Error("bad data");
conn.end();
return rows;
}
function main() {
for (let day = 1; day <= 5; day++) {
try {
console.log(report(day));
} catch (err) {
console.log(`day ${day} failed: ${err}`);
}
}
console.log(`leaked connections: ${live}`);
}
main();Here is the baseline in pool.ts: a fake Conn class standing in for a database client, plus a live counter so leaks are visible in the terminal. Every new Conn() increments it, every end() decrements it.
report() is the bug pattern to study. It opens a connection, queries, and calls conn.end() at the bottom. Looks fine in review. But day 3 throws before the end() line, so that connection never closes. Run node pool.ts (Node 24+) and the last line prints leaked connections: 1.
One leak per bad request is how a Postgres instance with the default max_connections of 100 falls over: each unreleased connection holds a server slot and a few megabytes of backend memory until something restarts.
The pre-2026 fix is try/finally, so write it once to feel the friction. conn.end() now runs on every exit path, and the leak counter drops to zero.
But look at the shape. The acquisition on line 28 and the cleanup on line 34 are six lines apart with the whole function body between them. Add a second resource and you either nest another try/finally or share one finally and null-check everything. Nothing enforces the pattern; the next person who adds an early return above the try reintroduces the leak.
That gap between acquire and release is the actual defect. ES2026's answer is to fuse them into one declaration.
A value is "disposable" when it has a method keyed by the well-known symbol Symbol.dispose. That method is the resource's own knowledge of how to clean itself up. Add one to Conn that simply delegates to end().
This is the whole protocol on the sync side, one method. Node's built-ins already implement it where it makes sense: the Timeout returned by setInterval is disposable, for example.
If your editor squiggles the symbol, set "lib": ["esnext"] in tsconfig.json so TypeScript picks up the esnext.disposable declarations. Nothing changes at runtime yet; the class just advertises the capability.
Now delete the try/finally and declare the connection with using instead of const. That single keyword tells the engine: when this scope exits, by any route, call [Symbol.dispose]() on this binding.
Run it again. Day 3 still throws, but the output shows close #3 firing anyway before the error propagates, and the final line reads leaked connections: 0. Five lines of ceremony became zero, and the guarantee got stronger, because it no longer depends on a human remembering anything.
Two rules worth knowing: a using binding is const (you cannot reassign it), and it accepts null or undefined as a no-op, which is handy for conditionally acquired resources.
Real drivers do not close synchronously. pg, mysql2, and every socket-backed client flush buffers and wait for the server before the connection is truly gone. That is what await using and [Symbol.asyncDispose] exist for.
Make the whole file honest about latency: query() and end() become async with a small sleep, Conn swaps Symbol.dispose for Symbol.asyncDispose, and report() declares the connection with await using. At scope exit the engine now awaits the disposal, so the function does not finish until the connection is genuinely closed.
One subtlety: await using requires an async context, and the exit-time await happens even when disposal is instant. Negligible next to a 5ms close, but a reason not to use it gratuitously in hot loops.
Disposables do not have to be heavyweight objects. Any factory returning something with [Symbol.dispose] works, so add time(): it captures performance.now() and logs the elapsed milliseconds when disposed. A profiler in nine lines, and it cannot forget to stop.
report() now holds two resources: the connection, then the timer. Disposal runs in reverse declaration order, LIFO, exactly like destructors. The timer (declared last) logs first, then the connection closes. That ordering is guaranteed by the spec, which matters when resource B depends on resource A, think transaction before connection.
The _t name is deliberate: the binding exists only for its cleanup, and ES2026 has no discard syntax yet, so an underscore-prefixed name keeps noUnusedLocals quiet.
Opening a fresh connection per request is the expensive part, real TCP plus TLS plus auth adds tens of milliseconds each time. Pools amortize that, and disposal composes with pooling beautifully: acquire() returns a lease object whose [Symbol.asyncDispose] does not close the connection, it returns it to the #idle list.
That means await using lease = pool.acquire() in report() gives you check-in on every exit path for free. Even the day 3 crash returns its connection. Watch the output: open #1 fires once, then every subsequent day reuses it. The 5 queries cost one connection setup instead of five.
But the final line now says leaked connections: 1, and that is correct: the idle connection outlives the loop, and nothing owns its shutdown yet. The pool itself has become the leak.
Disposables nest. The pool hands out disposable leases, and the pool itself can be disposable: add [Symbol.asyncDispose] that walks #idle and calls end() on each connection.
In main(), wrap the work in a bare block and declare the pool with await using. Blocks create disposal scopes just like function bodies, so when execution leaves the braces, the pool drains itself, and the log line after the block finally prints leaked connections: 0.
This is the shape production code wants: the pool lives exactly as long as the block that owns it. In a server you would tie that scope to process shutdown; in tests, to each test case, which kills the classic "Jest did not exit one second after" hang caused by stray handles.
The manual #idle loop has a hole: it only closes connections that made it back to the idle list. ES2026 ships a container built for exactly this, AsyncDisposableStack (and its sync twin DisposableStack). Give the pool one, route every new Conn() through stack.use(), which registers the resource and returns it, and let the pool's own disposal collapse to a single disposeAsync() call.
Now shutdown closes every connection the pool ever created, in reverse creation order, regardless of bookkeeping state. The stack also offers defer() for ad-hoc cleanup callbacks, adopt() for values that lack the symbols, and move() for transferring ownership out of a constructor, the standard trick for building disposable classes that cannot half-initialize.
One last spec detail: if a disposer throws while another error is already propagating, neither is swallowed. They arrive wrapped in a SuppressedError whose error property is the cleanup failure and whose suppressed property is the original, so nothing disappears the way a throwing finally used to eat exceptions. Run node pool.ts one final time: reused connections, timed queries, a survived crash, and leaked connections: 0.
The finished pool.ts demonstrates the entire ES2026 resource management surface: Symbol.dispose and Symbol.asyncDispose for making your own types disposable, using and await using for scope-tied cleanup, LIFO ordering for stacked resources, disposable lease objects for handing resources out safely, and AsyncDisposableStack for owning a dynamic collection of them.
Adoption guidance is straightforward. On the server, use it now: Node 24 and 26 run it natively, and Node's own APIs are already disposable, so await using file = await fs.promises.open(path) and using timer = setInterval(fn, ms) work today without any wrappers. In browser code, evergreen support is in place, and TypeScript 5.2+ transpiles the syntax for anything older, so there is no real blocker there either.
Two rough edges to know about. There is no discard binding yet, so a resource you only hold for its cleanup still needs a throwaway name like _t (a follow-on proposal covers using void). And each await using adds an awaited tick at scope exit, which is irrelevant for connections and file handles but worth knowing before you sprinkle it inside a hot inner loop. Neither changes the calculus: the pattern this replaces, try/finally with a manual release(), is exactly the code that quietly eats your max_connections budget when someone forgets it. Now the language remembers for you.