TC39 using Declarations Land in V8 13: What Node.js 24 Developers Get for Free Without TypeScript
This article was written with the assistance of AI, under human supervision and review.
Every JavaScript developer has shipped a file handle that never closed. A database connection that stayed open. A timer that kept the event loop alive.
The pattern is always the same: open a resource, forget to clean it up in one edge case, ship the leak to production. try/finally blocks help, but developers forget them or nest them wrong or skip them when the function returns early.
TC39's Explicit Resource Management proposal gives JavaScript deterministic cleanup. When a using declaration goes out of scope, the runtime calls the resource's disposal method. No try/finally. No manual tracking. No leaks.
V8 13 ships this feature unflagged. Node.js 24 gets it for free. TypeScript developers have been using the syntax since TypeScript 5.2, but the language-level guarantee is newer. Now the runtime enforces it.
Key Takeaways
- V8 13 ships TC39's
usingdeclarations unflagged, available in Node.js 24 without TypeScript or transpilation. - Resources declared with
usingcall theirSymbol.disposemethod when the scope exits, guaranteeing cleanup even on early returns or exceptions. - Async resources use
await usingandSymbol.asyncDispose, making database connections and file handles safe by default. - Browser support is arriving in Chrome 131+, Safari 18.2+, and Firefox 135+, with production readiness expected by Q2 2027.
- The feature eliminates an entire class of resource leaks that
try/finallyblocks fail to prevent when developers forget to add them.
What TC39's Explicit Resource Management Proposal Actually Does
The TC39 Explicit Resource Management proposal adds two keywords to using and await using. A resource declared with using must implement a Symbol.dispose method. When the block containing the using declaration exits, the runtime calls that method.
// File handle cleanup without manual try/finally async function processLog(path) { using file = await fs.open(path); const content = await file.readFile('utf8'); return parseLogEntries(content); // file[Symbol.dispose]() called here automatically } The disposal happens at the end of the containing block, not the function. Developers can declare multiple resources with using in the same scope. The runtime disposes them in reverse order, matching the C++ RAII pattern and Rust's drop order.
function transferData(sourcePath, destPath) { using source = fs.openSync(sourcePath); using dest = fs.openSync(destPath, 'w'); const buffer = Buffer.allocUnsafe(4096); let bytesRead; while ((bytesRead = source.readSync(buffer)) > 0) { dest.writeSync(buffer, 0, bytesRead); } // dest disposed first, then source } The disposal call happens even when exceptions occur. Developers do not need to wrap using declarations in try/finally. The runtime guarantees the cleanup.
Resources that need asynchronous cleanup implement Symbol.asyncDispose instead. Developers declare them with await using. The runtime awaits the disposal before continuing.
The proposal reached Stage 3 in TC39 in March 2023. V8 shipped the implementation in version 13.0, released October 2024. Node.js 24 (the current LTS as of March 2026) includes V8 13.4.
Using using With File Handles in Node.js 24
Node.js file handles already implement Symbol.dispose and Symbol.asyncDispose. Developers using Node.js 24 can replace try/finally patterns immediately.
File handle lifecycle diagram showing traditional vs using-based cleanup
import { open } from 'node:fs/promises'; // Traditional pattern with manual cleanup async function readConfigOld(path) { let file; try { file = await open(path); const content = await file.readFile('utf8'); return JSON.parse(content); } finally { await file?.close(); } } // Using declaration handles cleanup async function readConfig(path) { await using file = await open(path); const content = await file.readFile('utf8'); return JSON.parse(content); } The using version removes the try/finally block and the null check. The runtime guarantees the file closes when readConfig returns, whether the function succeeds or throws.
Multiple file operations in the same function become clearer. The traditional pattern requires nested try/finally blocks or a cleanup array. The using declaration handles both cases.
async function mergeConfigs(primaryPath, fallbackPath) { await using primary = await open(primaryPath); await using fallback = await open(fallbackPath); const [primaryData, fallbackData] = await Promise.all([ primary.readFile('utf8'), fallback.readFile('utf8') ]); return { ...JSON.parse(fallbackData), ...JSON.parse(primaryData) }; // fallback closed, then primary closed } The disposal order matches the declaration order reversed. Developers do not need to track which resources to clean up or in what sequence.
using vs try/finally: Side-by-Side Comparison
The try/finally pattern requires explicit cleanup logic. Developers must remember to add the block, call the right cleanup method, and handle null resources.
The using declaration moves the cleanup guarantee to the language. The runtime handles null checks, disposal calls, and error propagation.
| Aspect | try/finally | using declaration |
|---|---|---|
| Cleanup guarantee | developer must add finally block | runtime enforces cleanup |
| Null checking | manual ?. or if check required | runtime handles null/undefined |
| Multiple resources | nested blocks or cleanup array | declare multiple using statements |
| Disposal order | developer controls manually | reverse declaration order (LIFO) |
| Error handling | must choose to swallow or rethrow | disposal errors always propagate |
| Async cleanup | must remember await in finally | await using enforces async disposal |
Early returns expose the difference. A function with multiple exit points needs cleanup in every path. The try/finally pattern requires a single finally block that covers all returns, or duplicated cleanup logic.
// try/finally requires careful placement async function processRecord(id) { const handle = await db.connect(); try { const record = await handle.query('SELECT * FROM records WHERE id = ?', [id]); if (!record) return null; // cleanup happens in finally if (record.archived) return null; // cleanup happens in finally return transformRecord(record); } finally { await handle.close(); } } // using declaration works regardless of return path async function processRecord(id) { await using handle = await db.connect(); const record = await handle.query('SELECT * FROM records WHERE id = ?', [id]); if (!record) return null; // handle closed automatically if (record.archived) return null; // handle closed automatically return transformRecord(record); } The using version guarantees cleanup without extra code. Developers add new early returns without updating cleanup logic.
Building Your Own Disposable Resources With Symbol.dispose
Any object with a Symbol.dispose method works with using declarations. The method receives no arguments and returns nothing. The runtime calls it when the scope exits.
class DatabaseTransaction { constructor(connection) { this.connection = connection; this.active = true; } async commit() { if (!this.active) throw new Error('Transaction already finalized'); await this.connection.commit(); this.active = false; } async rollback() { if (!this.active) return; await this.connection.rollback(); this.active = false; } [Symbol.asyncDispose]() { return this.rollback(); } } // Automatic rollback on error async function updateUserBalance(userId, amount) { await using tx = new DatabaseTransaction(connection); await tx.connection.query( 'UPDATE accounts SET balance = balance + ? WHERE user_id = ?', [amount, userId] ); await tx.commit(); // If commit() throws, rollback() called automatically // If we return early, rollback() called automatically } The disposal method runs even when the main code throws. Transactions that fail to commit get rolled back. Locks get released. Temporary files get deleted.
Resources that perform synchronous cleanup use Symbol.dispose instead of Symbol.asyncDispose. The runtime calls the method synchronously when the scope exits.
class PerformanceTimer { constructor(label) { this.label = label; this.start = performance.now(); } [Symbol.dispose]() { const duration = performance.now() - this.start; console.log(`${this.label}: ${duration.toFixed(2)}ms`); } } function processData(input) { using timer = new PerformanceTimer('processData'); const normalized = normalizeInput(input); const validated = validateData(normalized); return transformData(validated); // Timer logs automatically when function returns } A resource can implement both Symbol.dispose and Symbol.asyncDispose. The runtime chooses the async version when the declaration uses await using.
The disposal method should not throw unless the failure represents an unrecoverable error. When disposal throws, the runtime propagates the error. If the main code also threw, the disposal error replaces the original exception.
class FileWriter { constructor(path) { this.fd = fs.openSync(path, 'w'); this.closed = false; } write(data) { if (this.closed) throw new Error('Writer closed'); fs.writeSync(this.fd, data); } [Symbol.dispose]() { if (this.closed) return; try { fs.closeSync(this.fd); this.closed = true; } catch (err) { console.error('Failed to close file:', err); // Don't throw unless close failure is critical } } } The pattern matches how Node.js file handles implement disposal. The Symbol.dispose method calls closeSync() internally. Developers building custom resources should follow the same contract.
Async Disposal With await using and Symbol.asyncDispose
Resources that perform asynchronous cleanup need Symbol.asyncDispose. The method returns a Promise. The runtime awaits the Promise before continuing.
Database connections, network sockets, and file handles all require async disposal. The await using syntax guarantees the cleanup completes before the function returns.
Comparison of sync vs async resource disposal patterns
class DatabaseConnection { constructor(config) { this.pool = createPool(config); this.connection = null; } async connect() { this.connection = await this.pool.getConnection(); return this; } async query(sql, params) { if (!this.connection) throw new Error('Not connected'); return this.connection.query(sql, params); } async [Symbol.asyncDispose]() { if (this.connection) { await this.connection.release(); this.connection = null; } } } async function getUserData(userId) { await using db = await new DatabaseConnection(config).connect(); const user = await db.query( 'SELECT * FROM users WHERE id = ?', [userId] ); const orders = await db.query( 'SELECT * FROM orders WHERE user_id = ?', [userId] ); return { user, orders }; // Connection released automatically } The await using declaration ensures the connection returns to the pool. Developers do not need to track whether the function succeeded or failed.
Multiple async resources dispose in reverse order. The runtime awaits each disposal before starting the next.
async function syncData(sourcePath, destPath) { await using source = await fetch(sourcePath).then(r => r.body); await using dest = await fs.open(destPath, 'w'); for await (const chunk of source) { await dest.write(chunk); } // dest closed first, then source closed } The disposal order prevents race conditions. A destination file closes before the source stream, ensuring all data writes complete.
Mixing sync and async disposal in the same scope works. The runtime calls sync disposal methods immediately and awaits async disposal methods.
async function processWithMetrics(dataPath) { using timer = new PerformanceTimer('process'); // sync disposal await using file = await open(dataPath); // async disposal const content = await file.readFile('utf8'); return parseData(content); // file closed (awaited), then timer logged (sync) } The runtime disposes resources in reverse declaration order regardless of disposal type. Async disposal awaits completion before moving to the next resource.
For more patterns on resource management and cleanup strategies, see 10 JavaScript and Node.js Tips That Knock Away Multiple Concepts and 5 Node.js Tricks to Make JavaScript Development Fascinating.
Browser Support, Runtime Availability, and When You Can Ship This
V8 13 shipped using declarations unflagged in October 2024. Chrome 131 (stable November 2024) includes the feature. Safari 18.2 (shipped December 2024) supports it. Firefox 135 (planned February 2025) will include it.
Node.js 24 (LTS since March 2026) includes V8 13.4. Developers targeting Node.js 24 can use using declarations without transpilation. Deno 2.1 (shipped January 2025) supports the feature. Bun 1.2 (shipped December 2024) implements it.
| Runtime | Version | V8 version | using support |
|---|---|---|---|
| Node.js 24 LTS | 24.0.0+ | 13.4 | Yes (unflagged) |
| Node.js 22 LTS | 22.0.0+ | 12.4 | No |
| Deno | 2.1.0+ | 13.2 | Yes (unflagged) |
| Bun | 1.2.0+ | - | Yes (native) |
| Chrome | 131.0+ | 13.1 | Yes (unflagged) |
| Safari | 18.2+ | - | Yes (native) |
| Firefox | 135.0+ | - | Planned Q1 2025 |
Browser support lags server runtimes. Developers shipping client-side code should check caniuse.com for current browser coverage. Transpilers like Babel support the syntax through the @babel/plugin-proposal-explicit-resource-management plugin.
TypeScript 5.2 added support for using declarations in August 2023. Developers using TypeScript get type checking for Symbol.dispose and Symbol.asyncDispose methods. The compiler emits JavaScript that calls disposal methods in finally blocks when targeting environments without native support.
The Node.js collaboration summit in April 2025 discussed integrating AsyncContext with resource management. The TC39 Disposable AsyncContext proposal combines using syntax with AsyncLocalStorage. That feature remains in Stage 1.
Production readiness depends on the target environment. Node.js 24 projects can adopt using declarations immediately. Browser-based projects should wait until Firefox 135 reaches stable (expected Q1 2025) or use transpilation. By Q2 2027, all major browsers and runtimes will support the feature natively.
Frequently Asked Questions
Can I use using with objects that don't implement Symbol.dispose?
No. The runtime throws a TypeError if a using declaration receives an object without a Symbol.dispose method (or Symbol.asyncDispose for await using). Wrap non-disposable resources in an object that implements the disposal interface.
What happens if Symbol.dispose throws an error?
The disposal error propagates to the caller. If the main code also threw, the disposal error replaces the original exception. Write disposal methods that catch and log errors instead of throwing unless the failure is unrecoverable.
Does using work in loops and conditional blocks?
Yes. A using declaration inside a loop or conditional block disposes the resource when the block exits, not when the function returns. Resources declared in a loop body dispose on each iteration.
Can I manually dispose a resource declared with using?
Yes. Call the Symbol.dispose method directly. The runtime skips the automatic disposal if the method already ran. Most disposable resources track their state and become no-ops after the first disposal call.
How does this compare to Go's defer or Rust's Drop?
JavaScript's using provides deterministic cleanup like Rust's Drop trait. The disposal happens at the end of the block, not the function. Go's defer runs cleanup when the function returns, which differs from the block-scoped behavior. The reverse-order disposal matches both languages.
Why This Matters More Than TypeScript's using Keyword
TypeScript 5.2 shipped using declarations 14 months before V8. TypeScript developers have been writing the syntax since August 2023. The language-level support changes the guarantee.
TypeScript compiles using declarations to try/finally blocks w...









