Writing · Article

JavaScript using Declarations: Deterministic Cleanup Without try/finally Pyramids

Explicit Resource Management brings using and await using to JavaScript. How Symbol.dispose, Symbol.asyncDispose and DisposableStack replace nested try/finally - with real code.

Every JavaScript codebase that touches files, sockets, locks, or observers ends up with the same shape of bug: a resource gets acquired, something throws, and the cleanup never runs. The classical defense is try/finally, and it works - right up until you hold three resources at once and your function becomes a staircase of nested finally blocks.

The Explicit Resource Management proposal - the using and await using declarations, backed by Symbol.dispose and Symbol.asyncDispose - fixes this at the language level, and it has now advanced through TC39 to the final stage and shipped in current V8-based runtimes. TypeScript has supported it since 5.2, so there is a good chance your toolchain already understands it. This post covers how it works, where it genuinely helps, and the sharp edges to know before you adopt it.

The problem: cleanup is manual and easy to drop

Here is the honest version of a function that opens a file handle and a stream in Node and cleans up properly:

async function processUpload(path) {
  const handle = await fs.open(path);
  try {
    const stream = handle.createReadStream();
    try {
      await parse(stream);
    } finally {
      stream.destroy();
    }
  } finally {
    await handle.close();
  }
}

Nothing here is wrong - it is just fragile. Each new resource adds a level of nesting, the acquisition and its cleanup drift further apart, and a refactor that adds an early return above the wrong line silently leaks. Linters cannot reliably save you, because they cannot know what counts as a resource.

using: scope-bound cleanup

A using declaration binds a value to the enclosing block, exactly like const - with one addition: when the block exits, for any reason, the runtime calls the value's [Symbol.dispose]() method. Normal completion, early return, throw, break - the cleanup runs on all of them, in the same deterministic way finally would.

class TempDir {
  constructor() {
    this.path = fs.mkdtempSync(os.tmpdir() + "/job-");
  }

  [Symbol.dispose]() {
    fs.rmSync(this.path, { recursive: true, force: true });
  }
}

function runJob() {
  using dir = new TempDir();
  writeArtifacts(dir.path);
  // dir is disposed here, even if writeArtifacts throws
}

The mental model: acquisition and cleanup are declared on the same line. You can no longer forget the cleanup, because the cleanup is not a separate statement you write - it is a protocol the resource carries with it.

Two rules follow from the const-like semantics. First, using bindings cannot be reassigned. Second, the declared value must be either null, undefined, or an object with a [Symbol.dispose] method - anything else throws a TypeError at declaration time, not at cleanup time. The null/undefined allowance is deliberate: it lets you write using lock = maybeAcquire() and skip cleanup when acquisition legitimately produced nothing.

await using: async teardown

Plenty of real teardown is asynchronous: closing a database connection, flushing a write stream, releasing a distributed lock. For those, await using calls [Symbol.asyncDispose]() and awaits the result before the block truly exits:

async function withConnection(url) {
  await using conn = await connect(url);
  // conn[Symbol.asyncDispose]() runs when this block exits,
  // and is awaited before execution continues
  return await conn.query("select 1");
}

Note the two awaits do different jobs: the first awaits acquisition (an ordinary promise), while the await in await using is about disposal. An await using declaration is only legal where await itself is legal - async functions and module top level.

It is not just files: DOM and observer cleanup

The protocol is just a method name, so anything can opt in - including ad-hoc objects wrapping browser APIs that need disconnect or removeEventListener calls:

function trackResize(el, onChange) {
  const observer = new ResizeObserver(onChange);
  observer.observe(el);
  return {
    observer,
    [Symbol.dispose]() {
      observer.disconnect();
    },
  };
}

function measureOnce(el) {
  using tracked = trackResize(el, sync);
  readLayout(el);
  // observer.disconnect() has run by the time we return
}

This pattern - return an object that carries its own [Symbol.dispose] - is the idiomatic bridge for APIs that predate the proposal. Libraries are increasingly shipping it natively, and in Node, several built-ins (timers, file handles, readline interfaces and more) have been growing disposable support since Node 20, with fresh additions landing through the Node 22 and 24 lines.

DisposableStack: dynamic and conditional resources

using covers the static case - a fixed set of resources known at write time. When you acquire a variable number of resources, or need to hand a bundle of them across a function boundary, reach for DisposableStack (and its async twin AsyncDisposableStack):

function acquireAll(paths) {
  using stack = new DisposableStack();
  const handles = paths.map(function (p) {
    return stack.use(openSync(p));
  });
  process(handles);
  // every handle opened so far is closed on exit,
  // in reverse order, even if one openSync throws halfway
}

The stack itself is disposable, so a single using stack line guards everything pushed onto it. It also has adopt for values that do not implement the protocol (you supply the cleanup callback), defer for bare cleanup functions with no value, and move for transferring ownership out of the current scope - the escape hatch for constructors that acquire resources but want to hand them to the instance on success.

Semantics worth memorizing

  • Disposal runs in reverse declaration order - last acquired, first released - matching how dependent resources are typically layered.
  • Errors thrown during disposal do not vanish: if the body also threw, both are packaged into a SuppressedError, so the original failure is never silently replaced.
  • Disposal is scope-based, not function-based: a using inside an if block or a bare { } block disposes at that block's end, which makes tight resource windows trivial to express.
  • using in a for...of loop body disposes at the end of each iteration - a common source of pleasant surprise in batch-processing code.
{
  using a = makeResource("a");
  using b = makeResource("b");
  // on block exit: b is disposed first, then a
}

Support and adoption strategy

As of mid-2026, using and await using are supported in current Chrome and Edge, in recent Firefox releases, and in Node from the 24 line onward (V8 shipped the feature to stable in 2025); Safari remains the browser to double-check before relying on native support. For anything older, TypeScript 5.2+ and Babel both transpile the syntax down to try/finally - you get the ergonomics today and native execution as targets catch up. Check your actual runtime matrix before shipping unpolyfilled syntax to browsers.

My adoption advice mirrors what worked for [iterator helpers](/blog/javascript-iterator-helpers) and the [Temporal API](/blog/javascript-temporal-api-practical-guide): start in code you fully control. Wrap your two or three most leak-prone resources - the database handle, the temp directory, the file lock - in Symbol.dispose, convert their call sites to using, and leave the rest of the codebase alone. The wins concentrate exactly where the try/finally pyramids used to live.

using will not change how you write a React component. It absolutely changes how you write scripts, servers, tests and tooling - the code where resources leak in the dark. Declare the cleanup on the acquisition line, and a whole category of bug stops being writable.