JavaScript using: Explicit Resource Management with using and await using
AI generated
JS
() =>
JavaScript · Resource Management · TC39 · Node.js
JavaScript using and await using
Explicit Resource Management without try/finally

The new using keyword in JavaScript automates releasing resources when a scope is left: database connections, file handles, locks and workers are guaranteed to be cleaned up, whether the block ends normally, throws an exception, or triggers an early return.

11 min read TC39 Stage 4 · TypeScript 5.2+ · Node.js 22+ using · await using · Symbol.dispose · DisposableStack

1. The problem with try/finally and manual cleanup

Resource leaks are one of the most common and hard-to-find classes of bugs in JavaScript applications. Database connections that never get returned, file handles that stay open, locks that never get released, and worker threads that never terminate all lead to gradually degrading performance, exhausted connection pools and hard-to-reproduce bugs. The problem almost always stems from early returns, unexpected exceptions, or simply forgotten cleanup code.

The classic pattern to avoid this is try/finally: the resource is created before the try block and released in the finally block, no matter what happens inside the try block. The pattern works, but it has significant ergonomic problems: it forces nested code, separates creation and cleanup far apart, makes the code hard to read, and scales poorly with multiple resources that must be released in reverse order. The new using keyword in JavaScript solves exactly this problem, as a language construct that guarantees resource cleanup, declared right where the resource is created.


// BEFORE: try/finally, verbose, error-prone, hard to read
async function processData() {
  const connection = await db.connect();
  let result;
  try {
    const fileHandle = await fs.open("data.csv", "r");
    try {
      const lock = await acquireLock("data-processing");
      try {
        result = await compute(connection, fileHandle, lock);
      } finally {
        await lock.release(); // must happen even on exception
      }
    } finally {
      await fileHandle.close(); // must happen even if lock.release() throws
    }
  } finally {
    await connection.close(); // must happen last
  }
  return result;
}

// AFTER: using, reads linearly, cleanup guaranteed at scope end
async function processData() {
  await using connection = await db.connect();  // auto-closes at scope end
  await using fileHandle = await fs.open("data.csv", "r"); // auto-closes
  await using lock = await acquireLock("data-processing"); // auto-releases

  return await compute(connection, fileHandle, lock);
  // All three dispose in reverse order when scope ends, always
}

2. using: automatic cleanup at scope end

The using keyword works like const and let: it declares a variable and binds it to a scope. The crucial difference: when the scope is left, the JavaScript engine automatically calls the object's [Symbol.dispose]() method. This happens in every case: normal scope end, a return statement, break, continue, and even when an exception is thrown. Multiple using declarations in the same scope are disposed in reverse order of their declaration, LIFO (Last In, First Out), exactly as is required for correct resource cleanup.

An object declared with using is immutable like const: you cannot reassign the variable. The object itself can still be mutated, but the binding is fixed, which makes the pattern easier to reason about: the resource is bound to the scope, the scope ends, the resource is released. This is the semantic analogue of C#'s using statement, Python's context managers (with) and Java's try-with-resources. JavaScript lacked an equivalent construct for a long time; using closes this gap as a genuine language feature, not as a library.

3. Implementing Symbol.dispose: making your own resources disposable

Every object meant to be used with using must implement the [Symbol.dispose]() method. This is a well-known symbol, a special, global symbol that the JavaScript engine recognizes and invokes at scope end. The implementation is simple: the method contains the cleanup code that would normally live in the finally block. Importantly, [Symbol.dispose]() can be synchronous, or for await using, asynchronous via [Symbol.asyncDispose]().

For classes that manage their own resources, connections, handles, locks, implementing [Symbol.dispose]() is the modern API ergonomic choice. Consumers of the class can then use using instead of manual close() calls. This not only reduces bugs from forgotten cleanup, it also makes the code more declarative: the resource is bound to the scope, and as soon as the scope ends, the resource is released. This is the correct mental model for resource management.


// Implementing Symbol.dispose on a custom resource class
class DatabaseConnection {
  #pool;
  #client;

  constructor(pool, client) {
    this.#pool = pool;
    this.#client = client;
  }

  async query(sql, params) {
    return this.#client.query(sql, params);
  }

  // Synchronous dispose: release connection back to pool
  [Symbol.dispose]() {
    this.#pool.release(this.#client);
    console.log("Connection returned to pool");
  }
}

// Factory function (returns a Disposable resource)
function acquireConnection(pool) {
  const client = pool.acquire();
  return new DatabaseConnection(pool, client);
}

// Usage: using handles cleanup automatically
function runQuery(pool, userId) {
  using conn = acquireConnection(pool);
  // conn[Symbol.dispose]() is called when this scope ends
  return conn.query("SELECT * FROM users WHERE id = $1", [userId]);
  // No need for try/finally, connection is always returned to pool
}

// Works with early returns too
function findUser(pool, userId) {
  using conn = acquireConnection(pool);
  const result = conn.query("SELECT * FROM users WHERE id = $1", [userId]);
  if (!result.rows.length) return null; // dispose() called here too
  return result.rows[0];
} // dispose() called here on normal exit

4. await using: asynchronous cleanup with Symbol.asyncDispose

await using is the asynchronous variant of using. When a resource needs to perform asynchronous work during cleanup, closing a network connection, completing a transaction, releasing a remote lock, the object implements [Symbol.asyncDispose](), which returns a promise. With await using, the JavaScript engine waits for that promise to settle before the next resource is disposed and before the calling code continues.

await using may only be used inside async functions, analogous to await. That is consistent: the caller must also be async in order to wait for the cleanup. If you need asynchronous cleanup but cannot make the caller async, DisposableStack with manually registered cleanup functions is the alternative. For most server-side Node.js applications, though, await using is the natural choice: database connections, HTTP clients and file system handles almost always have asynchronous close methods.

5. DisposableStack: managing multiple resources together

DisposableStack and AsyncDisposableStack are the ergonomic wrappers for more complex resource management scenarios. A DisposableStack collects multiple cleanup actions and runs them in reverse order on dispose, like a stack where the most recently added action runs first. This is especially useful when resources cannot be declared with using, for example because they are created in a loop or because conditional cleanup logic is required.

The defer() method of a DisposableStack accepts any function as a cleanup action, which allows registering callbacks for any kind of cleanup, not only for objects with [Symbol.dispose](). With move(), a DisposableStack can transfer its contents to another stack, which is useful when a factory function creates multiple resources and wants to hand them all to the caller together on success, while cleaning them all up together on failure.


// AsyncDisposableStack: manage multiple async resources together
async function processWithMultipleResources(config) {
  await using stack = new AsyncDisposableStack();

  // Register any async cleanup function
  const connection = await db.connect(config.dbUrl);
  stack.defer(async () => {
    await connection.close();
    console.log("Database connection closed");
  });

  const cache = await redisClient.connect(config.redisUrl);
  stack.defer(async () => {
    await cache.quit();
    console.log("Redis connection closed");
  });

  // Register a Disposable object directly
  const lock = await acquireLock("process-lock");
  stack.use(lock); // calls lock[Symbol.asyncDispose]() when stack disposes

  // All resources are released in reverse order (LIFO) when scope ends:
  // 1. lock (Symbol.asyncDispose)
  // 2. cache (defer callback)
  // 3. connection (defer callback)
  return await doWork(connection, cache);
}

// move(): transfer ownership to caller on success
async function buildResources(config) {
  await using tempStack = new AsyncDisposableStack();

  // Acquire resources (if anything fails, tempStack cleans up)
  const conn = await db.connect(config.dbUrl);
  tempStack.defer(async () => conn.close());

  const cache = await redis.connect(config.cacheUrl);
  tempStack.defer(async () => cache.quit());

  // Transfer ownership to caller, tempStack is now empty
  return { conn, cache, dispose: tempStack.move() };
}

6. Adapting existing APIs: wrappers without code changes

Existing JavaScript APIs have no [Symbol.dispose]() method, they need to be adapted. For APIs whose source code you control, implementing [Symbol.dispose]() directly on the class is the cleanest approach. For external libraries without [Symbol.dispose](), wrapper functions are the answer: they take the resource object, attach a [Symbol.dispose]() method, and return the extended object.

An elegant pattern: a generic asDisposable(resource, disposeFn) helper function that equips any object with a [Symbol.dispose]() method. This allows using using with any API without touching its source code. For Node.js streams, EventEmitter and other async resources, equivalent asAsyncDisposable() wrappers exist. The community has started publishing such wrappers as small libraries, but for most cases a local wrapper of five lines is enough.

7. using vs. try/finally vs. context managers compared

Comparing with other languages shows that JavaScript is closing a long-standing gap with using. Python has had context managers with with since version 2.5, C# has had using since version 1.0, Java try-with-resources since Java 7. JavaScript was the only major language without a resource management construct; using closes that gap.

Aspect try/finally using (JS) with (Python)
Cleanup guarantee Yes, if implemented correctly Yes, guaranteed by the language Yes, guaranteed by the language
Readability Nested with multiple resources Linear, declarative Linear, declarative
LIFO order Must be ensured manually Automatic Automatic
Async cleanup Yes, with await inside finally Yes, with await using Only from Python 3.10 (asynccontextmanager)
Error risk High, can be forgotten Minimal, structurally guaranteed Minimal, structurally guaranteed

The decisive advantage of using over try/finally is not brevity but structural correctness. With try/finally, it is possible to forget the finally block, reference the resource incorrectly, or get the order wrong with multiple resources. With using, these classes of bugs become structurally impossible; the compiler (TypeScript) or the runtime enforces the correct behavior.

8. TypeScript integration and the Disposable interface

TypeScript fully supports using starting with version 5.2. The type definitions include the interfaces Disposable and AsyncDisposable: Disposable requires the method [Symbol.dispose](): void, AsyncDisposable requires [Symbol.asyncDispose](): Promise<void>. Classes implementing these interfaces can be declared with using or await using; the TypeScript compiler statically checks the compatibility and raises an error if an object without [Symbol.dispose]() is declared with using.

TypeScript's type system makes using particularly valuable: library authors can type their resource classes as Disposable or AsyncDisposable, signaling that they are intended to be used with using. IDEs such as PhpStorm and VS Code surface the disposable status directly in autocomplete, an ergonomic improvement over the implicit knowledge that you need to call close().


// TypeScript 5.2+ (Disposable and AsyncDisposable interfaces)
interface DatabasePool {
  acquire(): Promise<PooledConnection>;
  release(conn: PooledConnection): void;
}

class PooledConnection implements AsyncDisposable {
  readonly #pool: DatabasePool;
  readonly #conn: RawConnection;

  constructor(pool: DatabasePool, conn: RawConnection) {
    this.#pool = pool;
    this.#conn = conn;
  }

  async query<T>(sql: string, params: unknown[]): Promise<T[]> {
    return this.#conn.query<T>(sql, params);
  }

  // TypeScript checks that this matches AsyncDisposable interface
  async [Symbol.asyncDispose](): Promise<void> {
    await this.#conn.rollbackIfActive();
    this.#pool.release(this.#conn);
  }
}

// using enforced: TypeScript error if PooledConnection lacks [Symbol.asyncDispose]
async function getUser(pool: DatabasePool, id: string) {
  await using conn = new PooledConnection(pool, await pool.acquire());
  const [user] = await conn.query<User>("SELECT * FROM users WHERE id=$1", [id]);
  return user ?? null;
  // conn[Symbol.asyncDispose]() guaranteed to run: rollback + pool release
}

// Helper: adapt existing APIs without modifying their source
function asDisposable<T>(resource: T, dispose: (r: T) => void): T & Disposable {
  return Object.assign(resource as T & Disposable, {
    [Symbol.dispose]() { dispose(resource); }
  });
}

// Usage: use any API with using
function readFile(path: string) {
  using fd = asDisposable(openFileSync(path), (f) => f.closeSync());
  return fd.readAllText();
}

9. Error handling: what happens if dispose() throws?

What happens if [Symbol.dispose]() throws an exception, especially in combination with an exception from the main code? This is a scenario with well-known problems in try/finally: if the finally block throws, it overwrites the original exception from the try block, and information about the original failure is lost. using solves this with a new exception type: SuppressedError. If both the main code and [Symbol.dispose]() throw, SuppressedError contains both: the original exception in error and the cleanup exception in suppressed. No error is ever lost.

The error behavior with multiple using declarations in the same scope is equally well thought out: if disposing one object throws, the engine still attempts to dispose all remaining objects in LIFO order. All exceptions are collected and returned as a nested SuppressedError chain. That means even when several cleanup actions fail, every one of them is still attempted; there is no early abort of the dispose process. This is the correct behavior for resource management, where all resources must be released even if individual releases fail.

10. Summary

The using keyword and await using are the missing piece for robust resource management in JavaScript. They replace the error-prone try/finally pattern with a structural language construct that guarantees cleanup when a scope is left, whether through normal completion, return, break, or an exception. The implementation is minimal: a single [Symbol.dispose]() or [Symbol.asyncDispose]() method on the resource object is enough. DisposableStack and AsyncDisposableStack extend the pattern for more complex scenarios with dynamically registered cleanup actions.

With TypeScript 5.2+, using is fully typed and statically checked; TypeScript prevents using objects without [Symbol.dispose]() with using. Polyfills are available for Node.js, and native support is arriving with V8 updates. The pattern that Python developers have long known with with and C# developers with using has now arrived in JavaScript, clean, extensible and without dependence on external libraries.

Mironsoft

JavaScript modernization, Node.js architecture and resource management

Eliminate resource leaks in Node.js?

We analyze existing Node.js applications for resource leaks, migrate try/finally patterns to using, and implement robust resource management with Symbol.dispose.

Leak analysis

Identifying connection leaks, unclosed handles and missing cleanup paths in Node.js apps

using migration

Migrating try/finally patterns to using, implementing Symbol.dispose and adopting DisposableStack

TypeScript integration

Introducing Disposable interfaces, TypeScript 5.2+ and statically checked resource management

JavaScript using: the essentials at a glance

Prerequisite: Symbol.dispose

Every object declared with using must implement [Symbol.dispose](). For async: [Symbol.asyncDispose]() returning a promise. TypeScript 5.2+ checks this statically.

Scope end means dispose

Cleanup happens on normal completion, return, break, continue and exceptions. LIFO order with multiple using declarations. No manual try/finally needed.

DisposableStack

For dynamically registered cleanup actions. defer() accepts any function. use() accepts Disposable objects. move() transfers ownership to another stack.

SuppressedError

If the main code and dispose() both throw, no exceptions are lost, SuppressedError contains both. LIFO dispose still runs to completion regardless.

11. FAQ: JavaScript using and Resource Management

1What is the using keyword?
A variable declaration that automatically calls [Symbol.dispose]() at scope end. Replaces try/finally, cleanup guaranteed on normal completion, return, break and exceptions.
2What does an object need for using?
The method [Symbol.dispose]() for sync, or [Symbol.asyncDispose]() for async (await using). TypeScript 5.2+ checks this statically via the Disposable/AsyncDisposable interfaces.
3using vs. await using?
using: synchronous, calls [Symbol.dispose](). await using: asynchronous, calls [Symbol.asyncDispose]() and waits for the promise. Usable only inside async functions.
4Order with multiple using?
LIFO, Last In, First Out. The most recently declared resource is disposed first. The correct order for resource management without manual tracking.
5What is DisposableStack?
Collects cleanup actions via defer() (any function) and use() (Disposable objects). LIFO on dispose. move() transfers ownership, useful for factory functions.
6If dispose() throws an exception?
SuppressedError: holds the original exception in .error and the cleanup exception in .suppressed. No exception is ever lost. LIFO dispose still runs to completion regardless.
7Existing APIs without Symbol.dispose?
Adapter: Object.assign(resource, { [Symbol.dispose]() { resource.close(); } }). Or DisposableStack.defer() with any cleanup function, no Symbol.dispose required.
8TypeScript version for using?
From TypeScript 5.2. Disposable and AsyncDisposable interfaces built in. Static check for whether [Symbol.dispose]() is present. Downlevel compilation to try/finally for older targets.
9Node.js support?
Native from Node.js 22 (V8 support). For older versions: Babel or TypeScript downlevel compilation to try/finally. TypeScript transpiles using automatically for older targets.
10using vs. garbage collection?
GC frees memory, not deterministically, and not for external resources. using releases connections, handles and locks deterministically at scope end, independent of the GC cycle.