DEV Community

janardhan reddy
janardhan reddy

Posted on

# JavaScript Finally Got Destructors, Sort Of, and Nobody Told Me For A Year

The Grafana alert said "connection pool: 100/100 in use." Nothing dramatic, no outage, just a slow climb over three days until a batch job started timing out waiting for a connection that was never coming. The culprit, once I actually went looking, was a helper function with an early return inside a try block, sitting above the finally that closed the connection. Somebody added that return six months earlier to handle an edge case, tested it, shipped it, and never noticed the finally two lines down had quietly stopped mattering for that one code path.

That bug is old as JavaScript itself. try/finally works fine until it doesn't, and it doesn't the moment your cleanup logic depends on every exit path being wired correctly by hand, forever, across every future edit. ES2026, ratified by ECMA International on June 30 this year, finally gives us a language-level answer: explicit resource management, via the using and await using declarations.

I'd seen the proposal floating around for a couple of years and mentally filed it under "neat, probably won't ship for a while." It shipped. Here's what it actually does.

The Problem It's Solving

Anything that needs cleanup, a database connection, a file handle, a lock, an event listener, a timer, has the same shape of risk. You open it, you use it, and somewhere along the way you need to close it, no matter how the function exits: normal return, early return, thrown error, whatever. try/finally is the tool for that today, and it works, but it's manual. Nest a few of these and the boilerplate multiplies:

async function processFile(path) {
  const file = await openFile(path);
  try {
    const conn = await pool.connect();
    try {
      const data = await file.read();
      await conn.query("INSERT INTO logs VALUES ($1)", [data]);
    } finally {
      conn.release();
    }
  } finally {
    await file.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Every resource adds a nesting level. Every nesting level is a chance to close things in the wrong order, or forget one entirely when you're refactoring at 90 miles an hour.

What using Actually Does

The using declaration ties a value's lifetime to the scope it's declared in. When that scope exits, for any reason, the runtime automatically calls a disposal method on the value. No finally required.

class DbConnection {
  constructor(conn) {
    this.conn = conn;
  }

  query(sql, params) {
    return this.conn.query(sql, params);
  }

  [Symbol.dispose]() {
    this.conn.release();
    console.log("connection released");
  }
}

function run() {
  using conn = new DbConnection(pool.connect());
  conn.query("SELECT 1");
  // conn[Symbol.dispose]() runs automatically here,
  // even if an error was thrown above
}
Enter fullscreen mode Exit fullscreen mode

Symbol.dispose is the new well-known symbol that makes an object "disposable." Anything implementing it can be declared with using, and the engine guarantees the dispose method runs when the block ends, whether it ends by falling through, returning, or throwing.

For anything where cleanup itself is asynchronous (closing a database pool that needs to flush, releasing a WebSocket that sends a close frame), there's await using, paired with Symbol.asyncDispose:

class FileHandle {
  constructor(handle) {
    this.handle = handle;
  }

  async read() {
    return this.handle.read();
  }

  async [Symbol.asyncDispose]() {
    await this.handle.close();
  }
}

async function processFile(path) {
  await using file = new FileHandle(await openFile(path));
  const data = await file.read();
  return data;
  // await file[Symbol.asyncDispose]() is awaited before this function
  // actually resolves
}
Enter fullscreen mode Exit fullscreen mode

Rewriting the earlier nested example with both:

async function processFile(path) {
  await using file = new FileHandle(await openFile(path));
  using conn = new DbConnection(await pool.connect());
  const data = await file.read();
  await conn.query("INSERT INTO logs VALUES ($1)", [data]);
}
Enter fullscreen mode Exit fullscreen mode

Two flat declarations instead of two nested try blocks. Disposal still happens in the right order too, reverse of declaration, so conn gets released before file gets closed, matching what the nested version did by hand.

One detail that trips people up the first time: using isn't a function call, it's a declaration keyword, sitting where const or let would go. The value on the right side of the assignment still has to actually implement Symbol.dispose or Symbol.asyncDispose. If it doesn't, you get a TypeError at the point of disposal, not at declaration, which is a slightly annoying place to discover a typo but at least it's loud about it. There's no ambiguity about ownership either. The variable declared with using owns the resource for the lifetime of that block, full stop, and nothing else needs to remember to release it.

DisposableStack, For When You Don't Know Up Front

Sometimes you're building up a set of resources conditionally and don't know at declaration time exactly what needs cleaning up. DisposableStack and its async counterpart exist for that:

function setupResources(options) {
  using stack = new DisposableStack();
  const conn = stack.use(new DbConnection(pool.connect()));

  if (options.needsCache) {
    const cache = stack.use(new CacheConnection());
  }

  return stack.move(); // hand off ownership, cleanup deferred
}
Enter fullscreen mode Exit fullscreen mode

Everything pushed onto the stack gets disposed in reverse order when the stack itself is disposed, or you can call .move() to transfer ownership somewhere else without triggering cleanup yet. It's a small thing, but if you've ever written a setup function that had to manually track "which of these three things did I actually open before this one failed," you'll recognize the itch it's scratching.

Where This Actually Matters In A React/Next.js Codebase

Most day-to-day component code won't touch this directly, React's own cleanup model (the return function from useEffect) already handles the common case. Where using earns its keep is lower down: database clients in route handlers, file system operations in build scripts, worker threads, anything talking to Node's fs, net, or a database driver that exposes disposal hooks. A few libraries have started shipping Symbol.dispose support on their client objects already, and more will as this settles in. If you maintain an internal wrapper around a connection pool or a temp file utility, that's a reasonable place to add [Symbol.dispose] now and get the syntax for free later.

Browser support and Node support both landed before the June ratification (engines had been shipping it behind flags for a while, since TC39 proposals at Stage 3 are considered stable enough to implement early), so this isn't a "wait three years" situation. Check your target runtime version, but the practical answer for most Next.js apps on recent Node is: it already works.

One catch if your team runs a shared TypeScript config: you'll need a recent target and lib setting for the compiler to recognize using and the two new well-known symbols. Older tsconfig presets copied from a project started a couple of years back won't know what to do with the syntax and will just error out, which looks scarier than it is. Bump the lib target, and it resolves itself.

So Was It Worth The Wait

Kind of. It doesn't fix bugs that already shipped, my connection pool leak from three paragraphs ago is exactly as leaked whether or not using exists, because it was written before this landed and nobody's going back to rewrite working code just because a nicer syntax showed up. What it does is remove the specific failure mode where correctness depends on a human remembering to nest try/finally correctly across every edit forever. That's a real thing to fix. It's also, in the end, syntax sugar over a pattern good engineers already enforced by hand and via linters.

I added Symbol.dispose to our internal db client wrapper this week. It shaved about four lines off each call site. Not a revolution. Just fewer chances to leave a connection open at 3 AM.

Top comments (0)