JavaScript's using Declaration Ends the try/finally Cleanup Era
July 14, 2026ECMAScript 2026 was ratified at the June Ecma General Assembly, and the biggest item in it is a single keyword: using. Explicit Resource Management, the TC39 proposal that hit Stage 4 back in early 2025, is now part of the language spec proper. It has been shipping in engines for over a year, TypeScript has transpiled it since 5.2, and with the standard stamped, there is no remaining excuse to keep writing nested try/finally blocks to close files, release locks, and clear timers.
This is the biggest change to how JavaScript handles cleanup since finally itself. The old way was a convention: you remembered to call .close(), .release(), .destroy(), or clearInterval() in a finally block, you got the nesting order right by hand, and you hoped nobody added an early return above your cleanup. The new way is a language feature: cleanup runs when a scope exits, in the right order, on every exit path, whether that exit is a return, a break, or a thrown exception.
What is a using declaration in JavaScript?
A using declaration binds a value whose [Symbol.dispose]() method is automatically called when the enclosing block exits, on every exit path including exceptions. Its async counterpart, await using, calls and awaits [Symbol.asyncDispose]() instead. Both are part of ECMAScript 2026 and are supported natively in current Chrome, Edge, Firefox, Node.js 24+, Deno, and Bun.
That is the whole mechanism. Everything below is what it unlocks, framed against what you had to write before.
1. Scope-tied cleanup replaces the try/finally pyramid
The old pattern for two resources was two levels of nesting, because each resource needs its own finally to guarantee release even if acquiring the next one throws:
const file = await fs.open("data.csv");
try {
const lock = await pool.acquire();
try {
await processRows(file, lock);
} finally {
await lock.release();
}
} finally {
await file.close();
}Three resources meant three levels. Every level pushed the actual work further right and further from the reader. Worse, the structure was load-bearing: flatten it naively into one try with one finally and you get a bug where a failed second acquisition skips cleanup of the first, or where cleanup calls run against variables that were never assigned.
The new version is flat:
async function processCsv() {
await using file = await fs.open("data.csv");
await using lock = await pool.acquire();
await processRows(file, lock);
}When processCsv exits, by any route, the lock is released and then the file is closed. Disposal order is strictly LIFO, the reverse of declaration order, which is exactly the nesting the try/finally pyramid encoded by hand. If pool.acquire() throws, file is still disposed, because it was already bound. The compiler-grade bookkeeping you used to do with indentation is now the engine's job.
A few semantics worth internalizing: using bindings are const-like (you cannot reassign them), they require an initializer, and they cannot be destructured. using also works inside for...of loop bodies and heads, so for (using conn of connections) disposes each connection at the end of its iteration, something the old world handled with a try/finally inside the loop or, more commonly, not at all.
One deliberately permissive rule: the initializer may be null or undefined, and disposal is skipped. That sounds sloppy but it is what makes conditional acquisition clean: using lock = needsLock ? acquire() : null; replaces the old let lock = null; try { ... } finally { if (lock) lock.release(); } dance.
2. Any object opts in with one well-known symbol
Before ES2026, every library invented its own cleanup vocabulary: close, dispose, destroy, release, unsubscribe, stop, abort. Callers had to read docs to learn which verb applied and remember to call it. Now there is one protocol. Implement [Symbol.dispose]() (or [Symbol.asyncDispose]() for async teardown) and every caller can manage your object with using:
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
class TempDir {
#path = fs.mkdtempSync(path.join(os.tmpdir(), "job-"));
get path() { return this.#path; }
[Symbol.dispose]() {
fs.rmSync(this.#path, { recursive: true, force: true });
}
}
function runJob(input) {
using scratch = new TempDir();
return buildArtifact(input, scratch.path);
} // scratch directory removed here, even on throwCompare that to the old shape, where TempDir would expose a cleanup() method and the README would carry a bolded warning to call it. Half the callers would. The protocol turns a documentation obligation into something the language enforces at scope exit, and it composes: an object that owns other disposables just disposes them from its own [Symbol.dispose].
This matters most for library authors. Ship Symbol.dispose on anything with teardown semantics, keep the old named method as an alias, and downstream code gets to delete finally blocks without waiting for a major version.
3. await using gives async teardown a real home
Async cleanup was the genuinely broken case before. finally blocks can await, but nothing forced them to, and forgetting the await on connection.end() meant the function resolved while teardown was still in flight. Test suites are full of this bug; it is the classic source of "Jest did not exit one second after the test run" warnings.
await using binds a resource whose [Symbol.asyncDispose]() is called and awaited at block exit. If the object only has a sync [Symbol.dispose], that is used as a fallback. The implicit await at scope exit is the point: the block does not finish until teardown does.
Two constraints follow from that implicit await. First, await using is only legal where await is legal: async functions and module top level. Second, block exit now yields to the microtask queue even when disposal is trivially fast, so code that depended on synchronous fallthrough after a block needs a second look. In practice that ordering change is invisible; in tight scheduling code it is worth knowing about.
Note the two awaits in await using file = await fs.open(...) do different jobs: the right-hand one awaits acquisition, the left-hand one is the declaration form that schedules awaited disposal. You need both.
4. SuppressedError stops cleanup from eating your exceptions
Here is a try/finally failure mode most developers cannot describe precisely, which is exactly the problem: if the try body throws and the finally block also throws, the finally error wins and the original exception vanishes. The error that told you what actually went wrong is gone; the error you see is the downstream symptom, a failed close() on a connection that was already broken. Debugging sessions have died in that gap.
ES2026 adds SuppressedError to fix this. When a using block's body throws and disposal also throws, you get a SuppressedError carrying both: .error is the newer exception from disposal, .suppressed is the original one it would otherwise have masked.
try {
using conn = connectSync(target);
throw new Error("handshake failed");
} catch (e) {
if (e instanceof SuppressedError) {
console.error("dispose failed:", e.error);
console.error("original error:", e.suppressed);
}
}If several disposals in the same block throw, the errors chain: each new SuppressedError wraps the previous one in .suppressed, so nothing is lost, ever. Error-reporting SDKs have started unwrapping these chains automatically; if you run your own logging pipeline, teach it to walk .suppressed or you will see only the outermost failure.
This is a real semantic improvement over try/finally, not just sugar. There was no non-awkward way to get this behavior by hand; the manual version involved catching in the finally, stashing errors in outer variables, and rethrowing an aggregate. Nobody wrote it.
5. DisposableStack replaces the ad-hoc cleanup array
Not every resource lifetime maps to a lexical block. Factories, constructors, and connection setup routines acquire several resources, and if step four fails, steps one through three must be torn down; if everything succeeds, ownership transfers to the returned object and nothing should be torn down yet. The old-world pattern was an array of cleanup callbacks plus a success flag, rebuilt slightly differently in every codebase.
ES2026 standardizes it as DisposableStack (and AsyncDisposableStack for async teardown). Three methods cover the acquisition patterns: use() for objects that already implement Symbol.dispose, adopt() for legacy objects plus a cleanup callback, and defer() for bare callbacks with no associated value. The stack is itself disposable, so you hold it with using. The unlock is move():
function createSession(opts) {
using stack = new DisposableStack();
const socket = stack.adopt(
openSocket(opts),
(s) => s.destroy()
);
const heartbeat = setInterval(() => ping(socket), 30_000);
stack.defer(() => clearInterval(heartbeat));
const session = new Session(socket, opts);
session.teardown = stack.move(); // transfer ownership
return session;
}If anything between the stack's creation and the return throws, the using disposes the stack and every acquired resource is released in LIFO order. On success, move() transfers everything to a fresh stack and leaves the original empty, so the using disposes nothing and the returned Session owns its resources, disposing the moved stack later from its own [Symbol.dispose]. That is the ownership-transfer idiom RAII languages have had for decades, expressed in nine lines with no flag variable.
DisposableStack is also the pragmatic bridge for the ecosystem's long tail: any library that never adopts the protocol can still be wrapped with adopt() at the call site, which means you do not have to wait for your dependencies to modernize before your own code does.
6. The platform is adopting the protocol
The keyword is only as useful as the objects that speak its protocol, and the platform side is filling in. In Node.js, FileHandle objects from fs.promises.open() implement Symbol.asyncDispose (closing the handle), and timers returned by setTimeout/setInterval from node:timers implement Symbol.dispose (clearing themselves), so using timer = setInterval(poll, 1000) cannot leak past its scope. Disposal is spreading through the rest of Node's API surface release by release, node:sqlite and friends included, and the WHATWG Streams spec has added disposal to stream readers and writers so that releasing a lock becomes a scope-exit effect rather than a releaseLock() call you can forget. Check your minimum runtime versions before leaning on any specific integration, but the direction is set: cleanup verbs are converging on the symbol.
AbortController deserves a mention because people reach for the wrong tool here. using does not replace it; they compose. A common pattern is stack.defer(() => controller.abort()), which turns cancellation into just another scope-exit effect.
Where you can run it today
Engine support arrived well before ratification. V8 shipped Explicit Resource Management in Chrome and Edge 134, and Firefox landed it in the same numbering window, all in early 2025; WebKit followed within the year. On the server, Node.js 24 and later support it unflagged, and Deno and Bun have both had it for some time. For anything older, TypeScript has downleveled using and await using since 5.2 in August 2023 (enable the esnext.disposable lib, or an es2026 lib target now that one exists), and Babel has an equivalent transform. There is no support story left to wait on: greenfield code should use the syntax natively, and transpiling codebases could have adopted it two years ago.
The one honest reason to move slowly is team convention, not tooling. A codebase where half the resources are managed with using and half with bare finally blocks is more confusing than either style alone. Pick a direction, add a lint rule, migrate the hot paths first: test fixtures, database access layers, anything that opens sockets.
The sharp edges
using is scope-based, not lifetime-based. Disposal fires when the block exits, full stop. If a reference escapes the block, whether stored on an object or captured in a closure, it now points at a disposed resource, and nothing warns you. This is not RAII tied to garbage collection, and the language deliberately does not try to be; when a resource must outlive the current scope, that is precisely what DisposableStack.move() is for.
Disposal running on every exit path also means it runs on paths you did not draw. An early return inside a using block disposes before the caller sees the returned value's side effects, and await using inserts an await at block exit even when the disposer is synchronous. Neither is a bug, but both can surprise code written with pre-2026 control-flow assumptions.
Finally, top-level using is legal in modules (disposal runs when module evaluation completes) but not in classic scripts, and using cannot appear with destructuring or without an initializer. The syntax errors are clear; you will hit them once and move on.
The overall judgment is easy to make. Explicit Resource Management is the rare TC39 feature that deletes code, deletes a bug class (leaked resources on early exits, swallowed exceptions in finally), and standardizes an idiom every serious codebase had already half-invented. Adopt it now for new code, wrap legacy objects with DisposableStack.adopt() where they resist, and let the try/finally pyramids retire attrition-style as files get touched. The language finally has a cleanup story worth committing to.