using and explicit resource management
Release files, locks and connections at a known moment with using and await using, write your own disposable objects, handle errors raised during cleanup, and manage groups of resources with DisposableStack.
What you will learn
- Declare resources with using and await using, and predict when and in which order they are disposed
- Make your own objects disposable with Symbol.dispose and Symbol.asyncDispose
- Read a SuppressedError when both the work and the cleanup fail
- Use Node's disposable file handles and temporary directories
- Collect, transfer and adopt resources with DisposableStack
On this page
Memory is cleaned up for you, but many other things are not. An open file, a database connection, a lock or a temporary directory must be released by your code, at the right moment, even when something throws. Until now the tool for that was try...finally, which works but grows awkward: three resources need three nested blocks, and it is easy to close them in the wrong order or forget one on an early return.
Explicit resource management adds two declarations, using and await using, that tie a resource to a scope. When the scope ends, for any reason, the resource is released. The proposal reached Stage 4 in May 2026 and is expected in ES2027 (finished proposals). Node 24 and 26 support it. Node 22 does not parse using at all: the file fails with SyntaxError: Unexpected identifier. In browsers it is not Baseline yet: Chrome and Edge 134 and Firefox 141 have it, Safari does not (MDN). This whole lesson needs Node 24 or later.
The disposable protocol
An object is disposable when it has a method under the well-known symbol Symbol.dispose (symbols as keys were covered in the symbols lesson). That method releases whatever the object holds. Here is a pretend connection that logs what happens:
class Connection { constructor(name) { this.name = name; console.log(`open ${name}`); } query(sql) { return `${this.name}: ${sql}`; } [Symbol.dispose]() { console.log(`close ${this.name}`); }}A using declaration looks like const: it must be initialised and cannot be reassigned. The difference is what happens at the end of the enclosing block or function.
function report() { using db = new Connection("db"); using cache = new Connection("cache"); console.log(db.query("SELECT 1")); return "report done";}console.log(report());open dbopen cachedb: SELECT 1close cacheclose dbreport doneBoth connections were closed when report returned, after the return value was computed and before the caller saw it. They were closed in reverse order of declaration. That order matters: a resource declared later may depend on an earlier one, so it must go first.
Disposal also happens when the scope is left by an exception. A value of null or undefined is allowed and simply skipped, which is handy for optional resources. Anything else without a Symbol.dispose method is a TypeError, thrown at the declaration line.
function failing() { using db = new Connection("db"); throw new Error("query failed");}try { failing();} catch (error) { console.log("caught:", error.message);}
{ using maybe = null; console.log("null is allowed:", maybe);}try { using plain = { name: "not disposable" };} catch (error) { console.log(error.name);}open dbclose dbcaught: query failednull is allowed: nullTypeErrorThe dispose method is looked up once, when the variable is declared. Replacing it afterwards has no effect on that declaration.
Where using is allowed
using works inside any block, in function bodies, at the top level of a module, and in the head of a for, for...of or for await...of loop, where each iteration’s value is disposed at the end of that iteration. It is a SyntaxError at the top level of a classic script, directly in a switch case (wrap the case body in braces), in a for...in head, and with destructuring: using { a } = obj is not allowed. Outside these declarations using is still an ordinary identifier, so old code that has a variable called using keeps working.
Errors during cleanup
What if the work throws and then the cleanup throws too? Neither error may be lost. JavaScript wraps them in a SuppressedError, a new Error subclass with two properties: error is the newer error, from disposal, and suppressed is the earlier one that it replaced.
const flaky = { [Symbol.dispose]() { throw new Error("close failed"); },};
try { using resource = flaky; throw new Error("work failed");} catch (error) { console.log(error.name); console.log("error:", error.error.message); console.log("suppressed:", error.suppressed.message);}SuppressedErrorerror: close failedsuppressed: work failedWhen only the cleanup fails, there is nothing to suppress, and you get the disposal error itself:
try { using resource = flaky; console.log("work succeeded");} catch (error) { console.log(error.name, error.message);}work succeededError close failedEvery disposer runs even if an earlier one threw, just like a finally block. With several failures the errors form a chain, which the third exercise unpicks.
await using
Some cleanup is asynchronous: flushing a buffer to disk, sending a goodbye message on a socket. An object is async disposable when it has a Symbol.asyncDispose method that returns a promise. await using waits for that promise when the scope ends, so it is allowed only where await is: in async functions and at the top level of a module.
import { setTimeout as sleep } from "node:timers/promises";
class BufferedLog { #lines = []; write(line) { this.#lines.push(line); } async [Symbol.asyncDispose]() { await sleep(10); console.log(`flushed ${this.#lines.length} lines`); }}A function that declares the log with await using waits for the flush before it returns:
async function job() { await using log = new BufferedLog(); log.write("start"); log.write("end"); console.log("job body done");}
await job();console.log("after job");job body doneflushed 2 linesafter jobjob did not finish until the flush did. The await is not where the variable is declared but where the scope ends. An await using declaration also accepts an object that has only Symbol.dispose, and calls it.
Node’s disposable resources
Node gives many of its own objects these methods. A FileHandle from fs/promises closes itself with Symbol.asyncDispose, and Requires Node.js 24.4 or later fsPromises.mkdtempDisposable(prefix) creates a temporary directory that is deleted, with its contents, on disposal (fs docs). Compare this with the withTempDir helper from the files lesson, which needed a callback:
import { mkdtempDisposable, open, readFile } from "node:fs/promises";import { existsSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";
let dirPath;{ await using dir = await mkdtempDisposable(join(tmpdir(), "ur-")); dirPath = dir.path; const notes = join(dir.path, "notes.txt"); { await using file = await open(notes, "w"); await file.write("written through a FileHandle\n"); } process.stdout.write(await readFile(notes, "utf8")); console.log("exists inside:", existsSync(dirPath));}console.log("exists after:", existsSync(dirPath));written through a FileHandleexists inside: trueexists after: falseNotice the two awaits on the declaration line: the first waits for the disposal at the end of the block, the second waits for mkdtempDisposable to create the directory. The inner block closes the file before it is read back.
Other Node objects with dispose methods include Worker (terminates the thread), ChildProcess (sends SIGTERM), timers, readable streams and directory handles from opendir. The Node documentation now prefers using over an AbortSignal for tearing down long-lived resources such as servers and child processes, and keeps signals for cancelling operations and timeouts (DEP0209, a documentation-only deprecation since Node 26.8.2).
DisposableStack
using binds one resource to one scope. Sometimes you need more control: a function that opens several resources and hands them to its caller, but closes the ones already opened if a later one fails. A DisposableStack holds any number of resources and disposes them all, in reverse order, when it is itself disposed.
stack.use(value)adds a disposable and returns it.stack.defer(fn)adds a plain cleanup callback.stack.adopt(value, fn)adds a value that is not disposable, with the function that releases it.stack.move()returns a new stack holding everything, and leaves the old one empty and disposed.
class Conn { constructor(name) { if (name === "bad") throw new Error(`cannot open ${name}`); this.name = name; console.log("open", name); } [Symbol.dispose]() { console.log("close", this.name); }}
function openPool(names) { using stack = new DisposableStack(); const conns = names.map((name) => stack.use(new Conn(name))); stack.defer(() => console.log("pool closed")); return { conns, cleanup: stack.move() };}If every connection opens, move() transfers them to the caller, and the using stack at the end of openPool disposes an empty stack. If one throws, move() is never reached, so the stack closes what was already opened.
{ const pool = openPool(["a", "b"]); using cleanup = pool.cleanup; console.log(pool.conns.length, "connections in use");}
try { openPool(["c", "bad"]);} catch (error) { console.log(error.message);}open aopen b2 connections in usepool closedclose bclose aopen cclose ccannot open badadopt brings older APIs into the scheme. A numeric file descriptor from openSync has no dispose method, but it can be adopted together with closeSync:
import { closeSync, openSync, readFileSync, writeSync } from "node:fs";import { mkdtempDisposable } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";
await using dir = await mkdtempDisposable(join(tmpdir(), "ur-"));const path = join(dir.path, "log.txt");{ using stack = new DisposableStack(); const fd = stack.adopt(openSync(path, "w"), (fd) => { closeSync(fd); console.log("closed descriptor"); }); writeSync(fd, "hello from a raw descriptor\n");}process.stdout.write(readFileSync(path, "utf8"));closed descriptorhello from a raw descriptorAsyncDisposableStack is the same with async cleanup functions, and is used with await using.
When not to use using
- When the resource must outlive the scope. A resource returned from a function, stored on an object or captured by a closure is disposed when the declaring scope ends, and later code holds a closed resource. Use
constand hand over ownership, or aDisposableStackwithmove(). - When the code must run on Node 22 or in Safari. Use
try...finallythere. On Node 22,Symbol.disposeexists and Node’s own objects already have the methods, sofinally { await file[Symbol.asyncDispose](); }works and makes later migration easy. - For memory. Garbage collection handles memory.
usingis for things the collector does not know how to release, or not soon enough.
Common pitfalls
- Forgetting
awaitinawait using.using file = await open(...)throws aTypeError, because aFileHandlehas only an async disposer. - Expecting cleanup in the order of declaration. It is the reverse order.
- Destructuring.
using { path } = diris a syntax error. Declare the object, then read its properties. - Putting
usingstraight into aswitchcase. Wrap the case body in a block. - Swallowing a
SuppressedError. Log botherrorandsuppressed, or the original failure is hidden behind the cleanup failure.
Exercises
Create a temporary directory with mkdtempDisposable and write two files into it, b.txt and then a.txt, through a helper writeText(path, text) that opens a FileHandle with await using. Inside the block, print the sorted names from readdir. After the block, print whether the directory still exists. The expected output is [ 'a.txt', 'b.txt' ] and then exists after: false.
Show solution
The helper closes each file before it returns, and the directory goes away with both files in it when the outer block ends.
import { mkdtempDisposable, open, readdir } from "node:fs/promises";import { existsSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";
async function writeText(path, text) { await using file = await open(path, "w"); await file.write(text);}The workspace block uses the helper twice and lists the result:
let workspace;{ await using dir = await mkdtempDisposable(join(tmpdir(), "ws-")); workspace = dir.path; await writeText(join(dir.path, "b.txt"), "second\n"); await writeText(join(dir.path, "a.txt"), "first\n"); console.log((await readdir(dir.path)).sort());}console.log("exists after:", existsSync(workspace));[ 'a.txt', 'b.txt' ]exists after: falseWrite a class Lock with a held getter and an acquire() method. acquire throws Error("already locked") if the lock is held; otherwise it marks it held and returns a disposable object that releases it. Show four things: held is true inside a using guard = lock.acquire() block; a second acquire there fails; held is false after the block; and it is also false after a block that throws. The expected output is inside: true, second acquire: already locked, after block: false, task crashed - held: false.
Show solution
The disposable is a small object whose arrow function closes over the lock. Arrow functions keep this from the method that created them.
class Lock { #held = false; get held() { return this.#held; } acquire() { if (this.#held) throw new Error("already locked"); this.#held = true; return { [Symbol.dispose]: () => { this.#held = false; }, }; }}The test covers the normal exit and the exit by an exception:
const lock = new Lock();{ using guard = lock.acquire(); console.log("inside:", lock.held); try { lock.acquire(); } catch (error) { console.log("second acquire:", error.message); }}console.log("after block:", lock.held);
try { using guard = lock.acquire(); throw new Error("task crashed");} catch (error) { console.log(error.message, "- held:", lock.held);}inside: truesecond acquire: already lockedafter block: falsetask crashed - held: falseDeclare two resources, a and then b, whose dispose methods throw close a failed and close b failed, and throw work failed in the block. Write history(error) that returns the messages of every error in the chain in the order they happened, and print it. The expected output is [ 'work failed', 'close b failed', 'close a failed' ].
Show solution
b is disposed first. Its error suppresses the work error, and then a’s error suppresses that SuppressedError. Following suppressed walks backwards in time, so the recursion puts the older errors first.
function failingClose(name) { return { [Symbol.dispose]() { throw new Error(`close ${name} failed`); }, };}
function history(error) { if (error instanceof SuppressedError) { return [...history(error.suppressed), error.error.message]; } return [error.message];}The block that produces the chain:
try { using a = failingClose("a"); using b = failingClose("b"); throw new Error("work failed");} catch (error) { console.log(history(error));}[ 'work failed', 'close b failed', 'close a failed' ]Summary
using x = valuedisposesvaluewithvalue[Symbol.dispose]()when the scope ends, normally or by an exception.nullandundefinedare skipped; any other value without the method is aTypeError.- Resources are disposed in reverse order of declaration, and every disposer runs.
await usingcalls and awaitsSymbol.asyncDisposeand is allowed only whereawaitis.- When the work and the cleanup both fail, a
SuppressedErrorholds both:errorfrom the cleanup,suppressedfrom before. - Node’s
FileHandle,mkdtempDisposableRequires Node.js 24.4 or later,Worker,ChildProcess, timers and streams are disposable. DisposableStackcollects resources withuse,deferandadopt, andmove()transfers them out of a scope.- Stage 4 since May 2026, expected in ES2027; Node 24 and later; not in Safari.
The next lesson stays in one thread and controls how much asynchronous work runs at once.