proposal-explicit-resource-management

Activity, over time

Daily GitHub activity and social mentions.

Mentions

Daily posts linking to this repository

Loading mentions chart

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Mentions

2posts · 30d
Nov 60 posts
  1. DEV · · @hirodeath

    What JavaScript using disposes after returns and exceptions: nine checks using registers an object with a disposal method in a scope and calls cleanup when that scope ends. When several resources were registered, they were disposed in reverse acquisition order. An early return followed the same rule. Nine conditions in Node.js covered disposal order, retained exceptions, and asynchronous waiting. Before using it, check which object supplies disposal and which scope exit triggers cleanup. Disposal finishes before the caller receives a return value This code ran as an .mjs file in Node.js 24.15.0, without a transpiler or polyfill: const events = []; function open(name) { events.push(`open:${name}`); return { [Symbol.dispose]() { events.push(`close:${name}`); } }; } function work() { using first = open('A'); using second = open('B'); events.push('body'); return 'done'; } const value = work(); console.log(JSON.stringify({ value, events })); The result contained value: "done", with events open:A → open:B → body → close:B → close:A. Both disposals had finished when the caller received the return value. open only returns a fixture object. The log records calls to Symbol.dispose; it is not a measurement of real files or connections. The ECMAScript declaration and scope specification also describes disposing resources registered through using when scope processing finishes. The comparison included failures as well as normal completion: | Condition | Observed order or result | | --- | --- | | Acquire A and B, finish normally | Dispose B, then A | | Acquire A and return | Dispose A before returning to the caller | | Throw in the body | Dispose A before entering catch | | Acquire A, then throw while acquiring B | Dispose registered A; no disposal call for B | | Body and both A/B disposals throw | Attempt both disposals and retain errors through SuppressedError | | Register A and B with await using | Finish B's asynchronous disposal before disposing A | | Pass an asyncDispose-only value to using | TypeError; asynchronous disposal is not called | | Pass null and undefined to using | Execute the body without disposal methods | | Throw while using a temporary directory | Directory no longer exists after entering catch | When acquiring B throws, using does not dispose B afterward. If the acquisition function allocated something before failing, that function needs to clean up its partial acquisition. Where does the body error go if cleanup fails too? A deliberately failing disposer tested this case. The body threw body failed, B threw close failed:B, and A threw close failed:A. The caught exception's error.message was close failed:A. The body failure was still retained: | Reference | Recorded message | | --- | --- | | error.error.message | close failed:A | | error.suppressed.error.message | close failed:B | | error.suppressed.suppressed.message | body failed | Both the outer and inner wrappers were SuppressedError objects. Cleanup continued after a disposal failed, with earlier errors retained in a related chain. The explicit resource management design material explains this relationship. Logging only the outer message can miss the body failure. The experiment followed both error and suppressed and checked each message. Use await using for asynchronous disposal The asynchronous fixture recorded the start and end of Symbol.asyncDispose separately. Registering await using a = ... and await using b = ... produced body → close start:B → close end:B → close start:A → close end:A → after work. The wait in this fixture was Promise.resolve(). It did not measure network or disk I/O duration. It did verify that A's disposal started after B's completed, and that the caller continued after both finished. Passing an object with only Symbol.asyncDispose to using without await produced a TypeError. An asynchronously disposable value cannot simply be placed in an ordinary using declaration. Check a real Node.js temporary directory The next example uses an actual temporary directory rather than only a logging object: import { existsSync, mkdtempDisposableSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; let path; try { using temporary = mkdtempDisposableSync(join(tmpdir(), 'article-using-example-')); path = temporary.path; writeFileSync(join(path, 'sample.txt'), 'fixture'); console.log('inside:', existsSync(path)); throw new Error('work failed'); } catch (error) { console.log('caught:', error.message); } console.log('after:', existsSync(path)); The output was inside: true, caught: work failed, and after: false. The directory, including sample.txt, was removed when the exception left the scope. Node.js 24.15.0's mkdtempDisposableSync returns an object with a path and a disposal method for removal. This API was added in 24.4.0. An arbitrary path string does not acquire that deletion behavior merely by being used in a using declaration. The tests ran on September 11, 2026, with Node.js 24.15.0 on macOS arm64. The nine-condition fixture and results are available. Other Node versions, browsers, transformed code, and forced process termination were not tested. These results cover disposal when JavaScript can execute the scope-exit steps.

  2. Stop Nesting try/finally for Cleanup. JavaScript Has using TL;DR: using file = ... calls file[Symbol.dispose]() the moment the block ends, whether it returned or threw. It ships in Chrome 134+, Edge 134+, Firefox 141+, Node 24+, Deno and Bun, but Safari has it only in Technology Preview, so it is not Baseline yet. The pyramid nobody wants to touch Every codebase has a function shaped like this: const file = await open(path, "w"); try { const timer = setInterval(report, 1000); try { await writeEverything(file); } finally { clearInterval(timer); } } finally { await file.close(); } Two resources, two finally blocks, four levels of indentation. Add a third resource and the function starts drifting off the right side of the screen. Forget one finally and you leak a file handle that only shows up in production at 3 a.m. The cleanup is always the same: undo things in the reverse order you did them. That is exactly the kind of bookkeeping a language should do for you. What it is Explicit resource management adds two declarations and a few helpers: | Piece | What it does | |---|---| | using x = value | Calls x[Symbol.dispose]() when the scope exits | | await using x = value | Awaits x[Symbol.asyncDispose]() when the scope exits | | DisposableStack | Collects cleanups by hand with use(), defer(), adopt() and move() | | AsyncDisposableStack | The same, for async cleanups | | SuppressedError | Carries a cleanup error together with the error it interrupted | It is a TC39 Stage 4 proposal, listed in TC39's finished proposals with an expected publication year of 2027. A minimal example Any object with a [Symbol.dispose]() method can be using-ed: function resource(name) { console.log(`open ${name}`); return { [Symbol.dispose]() { console.log(`close ${name}`); } }; } { using db = resource("db"); using cache = resource("cache"); console.log("working"); } console.log("after the block"); Output from Node 24.11: open db open cache working close cache close db after the block Last in, first out. The cache is closed before the database it might depend on, and you did not write a single finally. A realistic example: writing a file with a progress timer Node's FileHandle already has [Symbol.asyncDispose], so await using works on it out of the box. A timer is not disposable, but DisposableStack.defer() turns any cleanup function into one. import { open, readFile } from "node:fs/promises"; let lastFile; // only here so we can peek after the function returns async function exportCsv(path, rows) { await using file = await open(path, "w"); lastFile = file; using stack = new DisposableStack(); const timer = setInterval(() => console.log("still writing..."), 1000); stack.defer(() => { clearInterval(timer); console.log("timer cleared"); }); for (const row of rows) { if (!Array.isArray(row)) throw new Error(`bad row: ${row}`); await file.write(row.join(",") + "\n"); } return rows.length; } console.log("wrote", await exportCsv("ok.csv", [["id", "name"], [1, "Ada"]]), "rows"); console.log("fd after success:", lastFile.fd); console.log(JSON.stringify(await readFile("ok.csv", "utf8"))); try { await exportCsv("bad.csv", [["id"], "oops"]); } catch (e) { console.log("caught:", e.message); } console.log("fd after failure:", lastFile.fd); Output from Node 24.11: timer cleared wrote 2 rows fd after success: -1 "id,name\n1,Ada\n" timer cleared caught: bad row: oops fd after failure: -1 An fd of -1 means the handle is closed. Happy path or thrown error, the timer is cleared and the file is closed, in that order. The pyramid from the top of this post is now flat. Can I use it? On the server, yes: Node has it from 24.0, Deno from 2.2.10 and Bun from 1.3.0, according to MDN's compatibility data. In the browser, mostly. Chrome and Edge shipped it in 134, and Firefox in 141. Safari lists it only as "preview", which means Safari Technology Preview, and MDN marks the feature as Limited availability. If you ship to Safari users, keep the try/finally version or let your build tool transpile using for now. Gotchas 1. Not at the top level of a classic script. MDN says using is not allowed at the top level of a script, and Node's vm.Script throws SyntaxError: Unexpected identifier 'a' for using a = null;. Modules, functions and blocks are fine. A CommonJS file works too, because Node wraps it in a function. 2. The value must be disposable, or nothing. using x = { name: "no dispose" } throws TypeError: Symbol(Symbol.dispose) is not a function. null and undefined are allowed and simply skipped, which is handy for optional resources. 3. Cleanup errors do not swallow the real error. If the block throws and a disposer throws too, you get a SuppressedError. Its error is the cleanup failure and its suppressed is the original one, so check both before blaming the wrong line. 4. Closures can outlive the resource. This returns a function that reads an already closed resource: function getReader() { using r = { open: true, [Symbol.dispose]() { this.open = false; } }; return () => r.open; } console.log("still open?", getReader()()); // still open? false using ties the lifetime to the block, not to whoever holds a reference. 5. Constructors want move(). When an object opens two resources and the second one fails, the first should be closed. Build them on a stack and call move() only after everything succeeded: class Connection { #stack; constructor() { using stack = new DisposableStack(); this.db = stack.use(openDb()); this.cache = stack.use(openCache()); // if this throws, db is closed this.#stack = stack.move(); // success: we own them now } [Symbol.dispose]() { this.#stack.dispose(); } } In Node 24.11, a failing openCache() printed open db, close db, then the error. On success, nothing closes until the caller's own using conn = new Connection() goes out of scope. Wrap-up Cleanup code was always the part we copy, paste and forget. Find your deepest try/finally pyramid, give its resources a [Symbol.dispose], and let the block end do the work. Sources - MDN: using - MDN: DisposableStack - MDN: Firefox 141 release notes for developers - MDN browser-compat-data: DisposableStack - Web Platform Status: Explicit resource management - TC39: Finished proposals - TC39: Explicit Resource Management proposal This article was written with the help of AI and checked against the sources above.

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days