jsrosetta

Error handling

Resource Cleanup: using and defer

How Node.js's using/await using, Symbol.dispose, and DisposableStack compare to Go's defer: block vs function scope, LIFO order, and errors during cleanup.

By
Minimum versions
Node.js ≥ 24Go ≥ 1.20
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

Open a file, take a lock, connect to a database — whatever you open, you have to close, even when the code in between throws. For a long time JavaScript only had try/finally for this. The Explicit Resource Management proposal (TC39 Stage 4, part of ES2027) adds using / await using declarations: a variable declared with using automatically has its [Symbol.dispose]() (or await [Symbol.asyncDispose]()) called when control leaves the enclosing block. Go has had defer since its first release: the call is postponed until the enclosing function returns. Both clean up in reverse order of acquisition (LIFO), but they differ in scope — block vs function — and that is the easiest place to trip up.

Synchronous cleanup (using vs defer)

class Lock {
  constructor(name) {
    this.name = name;
    console.log(`lock ${name}`);
  }
 
  // Any object with a [Symbol.dispose]() method works with `using`.
  [Symbol.dispose]() {
    console.log(`unlock ${this.name}`);
  }
}
 
function transfer(fail) {
  using lock = new Lock("account");
  if (fail) throw new Error("insufficient funds");
  console.log("transferred");
} // lock[Symbol.dispose]() runs here, even on throw
 
transfer(false);
try {
  transfer(true);
} catch (err) {
  console.log("error:", err.message);
}
// → lock account
// → transferred
// → unlock account
// → lock account
// → unlock account
// → error: insufficient funds

Asynchronous cleanup (await using vs defer)

Closing a network connection or flushing a file is usually async in Node.js. await using calls await value[Symbol.asyncDispose]() when leaving the block. Go doesn't distinguish sync from async: Close() just blocks until it's done, so it's still plain defer.

import { open, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
 
const path = join(tmpdir(), "jsrosetta-using.txt");
 
class Connection {
  static async connect(name) {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log(`connected ${name}`);
    return new Connection(name);
  }
 
  constructor(name) {
    this.name = name;
  }
 
  // Async cleanup: `await using` awaits this method.
  async [Symbol.asyncDispose]() {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log(`disconnected ${this.name}`);
  }
}
 
async function exportReport() {
  await using db = await Connection.connect("db");
  await using file = await open(path, "w"); // fs/promises FileHandle implements Symbol.asyncDispose
  await file.write(`report from ${db.name}\n`);
  console.log("written");
} // await file.close(), then await db[Symbol.asyncDispose]()
 
try {
  await exportReport();
  console.log((await readFile(path, "utf8")).trim());
} finally {
  await rm(path, { force: true });
}
// → connected db
// → written
// → disconnected db
// → report from db

Several resources: LIFO order (DisposableStack vs defer)

Both languages close in reverse order: what was opened last is closed first, because it may depend on what was opened before it. When the number of resources is only known at runtime, JavaScript uses DisposableStack (AsyncDisposableStack for the async version); Go needs nothing extra, since every function already has a defer stack.

function acquire(name) {
  console.log(`open ${name}`);
  return { [Symbol.dispose]: () => console.log(`close ${name}`) };
}
 
function fixed() {
  using a = acquire("a");
  using b = acquire("b");
  console.log("work");
} // closed in reverse order of opening: b, then a
 
// The number of resources is only known at runtime: collect them in a DisposableStack.
function dynamic(names) {
  using stack = new DisposableStack();
  for (const name of names) {
    stack.use(acquire(name));
  }
  stack.defer(() => console.log("flush log")); // the closest thing to Go's `defer`
  console.log("work");
} // the stack disposes everything in LIFO order
 
fixed();
dynamic(["x", "y"]);
// → open a
// → open b
// → work
// → close b
// → close a
// → open x
// → open y
// → work
// → flush log
// → close y
// → close x

Scope: block (using) or function (defer)

This is the most important difference. using cleans up at the end of the block, so in a loop each iteration closes its own resource. defer only runs when the function returns — a defer inside a loop keeps every resource open until the end of the function.

function acquire(name) {
  console.log(`open ${name}`);
  return { [Symbol.dispose]: () => console.log(`close ${name}`) };
}
 
for (const name of ["a", "b"]) {
  using file = acquire(name);
  console.log(`use ${name}`);
} // `using` is block-scoped: disposed at the end of each iteration
console.log("loop done");
// → open a
// → use a
// → close a
// → open b
// → use b
// → close b
// → loop done

Errors during cleanup (SuppressedError vs errors.Join)

If the block body throws and then the cleanup throws too, which error wins? JavaScript wraps both in a SuppressedError: error is the new error (from dispose) and suppressed is the one it replaced (from the body). Go has no automatic mechanism — defer f.Close() silently discards the returned error; to keep it, use a named return and errors.Join.

function openFile(name) {
  return {
    [Symbol.dispose]() {
      throw new Error(`close ${name} failed`);
    },
  };
}
 
function save() {
  using file = openFile("a.txt");
  throw new Error("write failed");
}
 
try {
  save();
} catch (err) {
  // Both the body and dispose throw: the errors are wrapped in a SuppressedError.
  console.log(err instanceof SuppressedError); // → true
  console.log(err.error.message); // → close a.txt failed  (the newest error, from dispose)
  console.log(err.suppressed.message); // → write failed  (the error it suppressed, from the body)
}
 
// Only dispose throws: you get that error as-is, no SuppressedError.
try {
  using file = openFile("b.txt");
} catch (err) {
  console.log(err.message); // → close b.txt failed
}