DEV Community

Cover image for TC39 `using` Declarations Land in V8 13: What Node.js 24 Developers Get for Free Without TypeScript
jsmanifest
jsmanifest

Posted on Edited on Originally published at jsmanifest.com

TC39 `using` Declarations Land in V8 13: What Node.js 24 Developers Get for Free Without TypeScript

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.

Correction (2026-11-14): the first two code samples threw on Node.js 24. A promises FileHandle only implements Symbol.asyncDispose, so it needs await using, and fs.openSync returns a plain number that using cannot accept. Both samples are fixed below, along with the V8 and browser version details. Thanks to the Dev.to reader who ran them and reported it.

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.4 shipped this feature unflagged, and Node.js 24 (V8 13.6) 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 using declarations unflagged, available in Node.js 24 without TypeScript or transpilation.
  • Resources declared with using call their Symbol.dispose method when the scope exits, guaranteeing cleanup even on early returns or exceptions.
  • Async resources use await using and Symbol.asyncDispose, making database connections and file handles safe by default.
  • Browser support: Chrome and Edge 134+, Firefox 141+, and Safari 27.3+.
  • The feature eliminates an entire class of resource leaks that try/finally blocks 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.

import { open } from 'node:fs/promises';

// File handle cleanup without manual try/finally
async function processLog(path) {
  // A promises FileHandle only implements Symbol.asyncDispose,
  // so it needs `await using`. Plain `using` throws a TypeError here.
  await using file = await open(path);
  const content = await file.readFile('utf8');
  return parseLogEntries(content);
  // await file[Symbol.asyncDispose]() runs here automatically
}
Enter fullscreen mode Exit fullscreen mode

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.

Synchronous file descriptors need one extra step. fs.openSync returns a plain number, and using only accepts an object (or null/undefined), so using fd = fs.openSync(path) throws TypeError: An object is expected with using declarations. DisposableStack bridges the gap: register each close call with defer(), and the stack runs them in reverse order when it is disposed.

import fs from 'node:fs';

function transferData(sourcePath, destPath) {
  using stack = new DisposableStack();

  const source = fs.openSync(sourcePath);
  stack.defer(() => fs.closeSync(source));

  const dest = fs.openSync(destPath, 'w');
  stack.defer(() => fs.closeSync(dest));

  const buffer = Buffer.allocUnsafe(4096);
  let bytesRead;

  while ((bytesRead = fs.readSync(source, buffer)) > 0) {
    fs.writeSync(dest, buffer, 0, bytesRead);
  }
  // dest closed first, then source
}
Enter fullscreen mode Exit fullscreen mode

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 it unflagged in version 13.4 (Chrome 134, March 2025). Node.js 24.0.0 (May 2025, LTS since October 2025) ships V8 13.6. Check your own runtime with node -p process.versions.v8.

Using using With File Handles in Node.js 24

The FileHandle objects returned by node:fs/promises implement Symbol.asyncDispose (not Symbol.dispose), so they pair with await using. Developers using Node.js 24 can replace try/finally patterns immediately.

File handle lifecycle diagram showing traditional vs using-based cleanup

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 runtime throws a SuppressedError whose error is the disposal error and whose suppressed is the original exception, so neither is lost.

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping the raw descriptor in a class like this is the other way, besides DisposableStack, to make a sync fs.openSync descriptor work with using. Make disposal idempotent and quiet, the same contract Node.js's own FileHandle follows for Symbol.asyncDispose.

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

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
}
Enter fullscreen mode Exit fullscreen mode

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(sourceUrl, destPath) {
  // A fetch() body (ReadableStream) has no Symbol.asyncDispose in Node.js 24,
  // so it cannot be bound with `await using`. Only the file handle is.
  const response = await fetch(sourceUrl);
  await using dest = await open(destPath, 'w');

  for await (const chunk of response.body) {
    await dest.write(chunk);
  }
  // dest closed (awaited) when the function exits
}
Enter fullscreen mode Exit fullscreen mode

The await on disposal matters: the function does not return until the destination file has actually closed, so callers never see a half-flushed file.

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)
}
Enter fullscreen mode Exit fullscreen mode

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.4 shipped using declarations unflagged with Chrome 134 in March 2025. Node.js 24.0.0 followed in May 2025 with V8 13.6. Firefox added support in version 141, and Safari in 27.3 (per MDN's compatibility data).

Developers targeting Node.js 24 can use using declarations without transpilation. Deno supports using since 1.37 and await using since 2.2.10. Bun has supported both since 1.0.23.

Runtime using / await using since V8 version
Node.js 24 LTS 24.0.0 13.6
Node.js 22 LTS not supported 12.4
Deno 1.37 / 2.2.10 -
Bun 1.0.23 - (JavaScriptCore)
Chrome / Edge 134 13.4
Firefox 141 - (SpiderMonkey)
Safari 27.3 - (JavaScriptCore)

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 that still need to reach older Safari or Firefox versions should keep transpiling until their support floor reaches Safari 27.3 and Firefox 141.

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 caller receives a SuppressedError carrying both: .error is the disposal error and .suppressed is 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, but the runtime still calls Symbol.dispose again when the scope exits; it does not track manual calls. Make the method idempotent (track a disposed flag and return early), as the FileWriter example above does.

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 when targeting runtimes without native support. The cleanup happens, but the TypeScript compiler generates the code. Developers targeting older runtimes still ship transpiled JavaScript.

V8's native implementation moves the cleanup guarantee to the runtime. The disposal call happens regardless of transpilation. Developers targeting Node.js 24 skip the build step entirely. No TypeScript compiler. No Babel plugin. The runtime enforces cleanup.

The feature eliminates a class of bugs that try/finally blocks fail to prevent. Developers forget to add cleanup code. They nest blocks wrong. They return early without cleanup. The using declaration removes those failure modes.

Database connections stay open. File handles leak. Network sockets time out. These failures happen in production because cleanup logic lives in application code. The runtime guarantee moves cleanup from developer discipline to language semantics.

Node.js 24 makes this guarantee available without TypeScript. Developers running plain JavaScript get deterministic cleanup. The runtime handles disposal. The feature works in scripts, REPL sessions, and production code. For more on leveraging modern Node.js features, see Convenient Tactics of Logging in Node.js and JavaScript in the Browser.

Every resource leak that ships to production represents a try/finally block developers forgot to write. The using declaration removes that decision. Cleanup happens automatically. The runtime guarantees it.

Sources

Top comments (1)

Collapse
 
sunnatulla_dev profile image
Sunnatulla •

Ran the first two snippets on Node 24.22 (V8 13.6) and both throw.

using file = await fs.open(path) fails with TypeError: Symbol(Symbol.dispose) is not a function, because a promises FileHandle only has Symbol.asyncDispose. It has to be await using, like you do in readConfig further down.

using source = fs.openSync(sourcePath) throws An object is expected with using declarations. openSync returns a plain fd number, and there's no source.readSync either. For sync code with fds, DisposableStack works:

function readSync(p) {
  using stack = new DisposableStack();
  const fd = fs.openSync(p);
  stack.defer(() => fs.closeSync(fd));
  return fs.readFileSync(fd, 'utf8');
}
Enter fullscreen mode Exit fullscreen mode

Small thing on versions too: Node 24 ships V8 13.6, not 13.4 (node -p process.versions.v8). Worth fixing the snippets since people will copy the first one and get the TypeError straight away.