Explicit resource management
Learn using, await using, DisposableStack, and SuppressedError to clean up files, locks, listeners, timers, and object URLs reliably.
- 01Release resources deterministicallyUse
usingdeclarations and[Symbol.dispose]()to clean up handles on every block exit path. - 02Handle async cleanupChoose
await usingand[Symbol.asyncDispose]()when closing a resource returns a promise. - 03Group and debug cleanupApply
DisposableStack,AsyncDisposableStack, andSuppressedErrorwithout hiding the original failure.
Why cleanup needs help
Garbage collection reclaims memory when objects become unreachable. It does not close a file descriptor, release a mutex, roll back a transaction, unsubscribe from a feed, cancel a timer, remove a temporary DOM listener, or revoke a blob URL at the exact moment your code stops needing it.
Those are resources: handles to something outside ordinary JavaScript memory. They need a deterministic release step. Before explicit resource management, the reliable answer was try/finally. That works, but several resources make it verbose and easy to get wrong.
Explicit resource management is JavaScript syntax and protocol for deterministic cleanup. A using declaration registers a resource with a scope. When the scope exits, JavaScript calls [Symbol.dispose](); for async cleanup, await using awaits [Symbol.asyncDispose]().
- file handles
- locks
- database connections
- subscriptions
- timers
- temporary DOM listeners
- object URLs
Imagine borrowing equipment from a studio. You might pick up a camera first, then a tripod, then a visitor badge. When you leave, the badge goes back first, then the tripod, then the camera. The return desk is not about memory; it is about releasing the studio's limited equipment on time.
- In real life: Borrow a camera, tripod, and badge
- In JavaScript: Acquire a file, lock, and subscription
- In real life: Leave the building
- In JavaScript: Leave the block, function, or module scope
- In real life: Return the newest item first
- In JavaScript: Dispose in reverse declaration order
Where the analogy stops: A real checkout clerk can remind you about forgotten items. JavaScript only disposes values that were actually registered by using or a stack.
const events = [];let file;let lock; function open(name) { events.push(`open ${name}`); return { close: () => events.push(`close ${name}`) };} try { file = open("file"); lock = open("lock"); events.push("work");} finally { if (lock) lock.close(); if (file) file.close();} console.log(events.join(" -> "));Line 10 opens the file and line 11 acquires the lock. Lines 14 and 15 must remember to release them in reverse order. Add a third resource, a conditional branch, or an early return, and the cleanup bookkeeping starts to hide the work you meant to describe.
| Tool | Best use | Trade-off |
|---|---|---|
| try/finally | Works everywhere and can express any cleanup. | Gets noisy with several resources, reverse order is manual, and a missed finally leaks. |
| using | Best for one or more block-scoped synchronous resources. | Needs native parser support or TypeScript downleveling, and the value must have [Symbol.dispose](). |
| DisposableStack | Best when resources are acquired conditionally or inside helper branches. | Still deterministic, but you must register each cleanup and dispose or using the stack. |
| FinalizationRegistry | Useful for backup notifications after garbage collection. | Not deterministic. It cannot replace closing files, locks, sockets, object URLs, or listeners. |
using and Symbol.dispose
STEP THROUGHA synchronous disposable object has a method named by the well-known symbol Symbol.dispose. The declaration using file = openFile() says: keep file visible in this scope, then call file[Symbol.dispose]() when the scope exits.
Disposal happens even if the block throws or returns early. Multiple declarations are disposed in reverse declaration order. Step through the same source with three exit paths and watch the trace prove it.
Step through the block exit. Change the mode to prove cleanup happens on normal completion, early return, and thrown errors.
script
function resource(name) { events.push(`acquire ${name}`); return { [Symbol.dispose]() { events.push(`dispose ${name}`); }, };} function work() { using file = resource("file"); using lock = resource("lock"); events.push("work"); if ("normal" === "throw") throw new Error("write failed"); if ("normal" === "return") return "returned early"; events.push("done"); return "finished";} try { console.log(work());} catch (error) { console.log(error.message);}console.log(events.join(" -> "));null and undefined are allowed and skipped. Any other value must provide the correct method. A plain object without [Symbol.dispose] causes a TypeError while the declaration is created.
{ using nothing = null; using alsoNothing = undefined; using handle = { [Symbol.dispose]() { console.log("disposed handle"); }, };} try { { using broken = {}; }} catch (error) { console.log(error instanceof TypeError); console.log(error.name);}Line 1 and line 2 are legal no-ops. Line 3 creates a real disposable object, so its method runs when the block ends. Line 12 shows the sharp edge: is not nullish and has no dispose method, so JavaScript throws TypeError instead of guessing.
await using waits for async cleanup
ASYNCSome cleanup is asynchronous: flushing a stream, closing a database connection, releasing a remote lock, or rolling back a transaction. For those resources, implement [Symbol.asyncDispose]() and declare them with await using. The scope does not finish until each async disposer has been awaited.
await using is for resources whose cleanup itself returns a promise. Step through the awaited reverse-order cleanup.
script
(async () => { function connection(name) { events.push(`open ${name}`); return { async [Symbol.asyncDispose]() { events.push(`start close ${name}`); await Promise.resolve(); events.push(`closed ${name}`); }, }; } { await using db = connection("db"); await using stream = connection("stream"); events.push("query"); } console.log(events.join(" -> "));})();The source deliberately logs after the block. That final log only runs after the stream closes and then the database closes. Reverse order still matters; the newer stream closes before the older connection.
const events = []; function* rows() { yield { name: "A", [Symbol.dispose]() { events.push("close A"); }, }; yield { name: "B", [Symbol.dispose]() { events.push("close B"); }, };} for (using row of rows()) { events.push(`read ${row.name}`);} console.log(events.join(" -> "));for (using row of rows()) creates a fresh disposable binding for each loop iteration. The row from one iteration closes before the next row is read, which is useful for cursors, paged handles, and temporary locks that should not overlap.
DisposableStack and AsyncDisposableStack
STACKSA block can acquire resources through helper branches: one URL here, one lock there, a cache callback only if an option is enabled. A stack gives that scattered code one owner. Register every cleanup as it is acquired, then dispose the stack once.
Step through a stack that combines defer, use, adopt, move, dispose, and disposed.
script
const stack = new DisposableStack();stack.defer(() => events.push("defer cache"));stack.use({ [Symbol.dispose]() { events.push("close file"); },});stack.adopt("lock", (name) => events.push(`release ${name}`));const moved = stack.move();console.log(stack.disposed);moved.dispose();console.log(moved.disposed);console.log(events.join(" -> "));The stack API has a small vocabulary. use(value) registers a value that is already disposable. adopt(value, onDispose) wraps a plain value and cleanup function. defer(fn) schedules a callback. move() transfers ownership to a new stack. dispose() runs cleanup, and disposed tells you whether the stack is closed.
(async () => { const events = []; { await using stack = new AsyncDisposableStack(); stack.defer(async () => events.push("defer metrics")); stack.use({ async [Symbol.asyncDispose]() { events.push("close socket"); }, }); stack.adopt("transaction", async (value) => events.push(`rollback ${value}`)); events.push("inside"); } console.log(events.join(" -> "));})();Use AsyncDisposableStack when one or more cleanup callbacks return promises. Put it behind await using so the stack itself is awaited at scope exit.
`using lock = acquireLock()``await using db = await connect()``stack.defer(() => revoke(url))``new FinalizationRegistry(cleanup)``finally { file.close(); }``stack.use(fileHandle)`
Sort each card by how cleanup is controlled. Deterministic includes try/finally and direct using; stack-managed means a DisposableStack owns the callbacks.
SuppressedError: when cleanup fails too
REAL OUTPUTCleanup can fail. A file close may throw, a rollback may reject, or a listener removal may hit a host error. If that happens while another error is already leaving the block, JavaScript throws a SuppressedError so neither failure is lost.
try { { using handle = { [Symbol.dispose]() { throw new Error("cleanup failed"); }, }; throw new Error("work failed"); }} catch (error) { console.log(error instanceof SuppressedError); console.log(error.error.message); console.log(error.suppressed.message);}The printed output proves the shape: the caught value is a SuppressedError. Its error property is the cleanup failure (cleanup failed), and its suppressed property is the earlier work failure (work failed). That is the same debugging instinct you practiced in custom errors and error handling strategies: preserve the causal chain instead of overwriting it.
Practical patterns in apps
BROWSERThe smallest useful helper returns a disposable object. That keeps acquisition and release in the same function, so call sites cannot forget which cleanup function matches which setup function.
function listen(target, type, handler, options) { target.addEventListener(type, handler, options); return { [Symbol.dispose]() { target.removeEventListener(type, handler, options); }, };} const button = document.querySelector("#save") ?? document.body.appendChild(document.createElement("button")); { using click = listen(button, "click", () => console.log("clicked")); button.click();} button.click();console.log("listener removed");The first click logs because the listener is alive inside the block. The second click logs nothing because line 4 removed the listener when the block exited. This pattern works for temporary focus traps, one-off measurements, and listeners attached during a drag gesture.
function objectUrlFor(blob) { const url = URL.createObjectURL(blob); return { url, [Symbol.dispose]() { URL.revokeObjectURL(url); console.log("revoked object URL"); }, };} { using preview = objectUrlFor(new Blob(["hello"], { type: "text/plain" })); console.log(preview.url.startsWith("blob:"));}Object URLs are a common browser leak. A photo preview can create a URL, assign it to an image, and revoke it when the preview scope ends. The wrapper makes URL.revokeObjectURL impossible to miss. This example is not run in the sandboxed editor because object URL lifetimes belong to the preview document that owns the blob.
async function enterTransaction(db) { const transaction = await db.begin(); return { transaction, async [Symbol.asyncDispose]() { if (transaction.open) await transaction.rollback(); }, };} async function saveInvoice(db, invoice) { await using scope = await enterTransaction(db); await scope.transaction.insert(invoice); await scope.transaction.commit();}Locks and transactions often need await using. If insert throws, the transaction is still open, so the disposer rolls it back. If commit closes it first, the disposer becomes a safe no-op.
Runtime support, syntax placement, and TypeScript
CHECK FIRSTMDN browser data lists explicit resource management as available in Chrome and Edge 134+, Firefox 141+, Node.js 24+, and Safari Technology Preview. That is not the same as saying every user's browser supports it. Feature-detect before offering runnable examples, and use TypeScript downleveling when you need older runtimes.
function supportsUsingSyntax() { try { new Function("{ using x = null; }"); return true; } catch { return false; }} console.log(supportsUsingSyntax());Not checked yetThe check runs in the browser because Node 22 can load this lesson but cannot parse using syntax.
Symbol.dispose alone, because Node 22 has the symbols but rejects the syntax.| Area | Status | What to remember |
|---|---|---|
| Browsers | Chrome and Edge 134+, Firefox 141+, Safari Technology Preview | MDN marks explicit resource management as limited availability because stable Safari support is still pending. |
| Node.js | Node 24+ parses using; Node 22 does not | Node's FileHandle exposes [Symbol.asyncDispose]() and timer handles expose [Symbol.dispose]() in the modern line. |
| TypeScript | TypeScript 5.2+ | The compiler accepts using / await using and can downlevel the cleanup machinery to helper code for older targets. |
| Feature detection | Parser check | Use new Function("{ using x = null; }") inside a try block before offering runnable using code. |
| Location | Allowed? | Example |
|---|---|---|
| Block or function body | Allowed | { using handle = open(); } or function f() { using handle = open(); } |
| Module top level | Allowed | A module has its own lexical scope, so top-level using is valid there. |
| Classic script top level | SyntaxError | Wrap it in a block or use <script type="module">. |
| Loop declaration | Allowed | for (using row of rows()) disposes each iteration's binding before the next iteration. |
<script> // SyntaxError in a classic script's top level. using handle = openHandle();</script> <script type="module"> // Module top level is allowed. using handle = openHandle();</script> <script> // Blocks and functions are allowed in classic scripts. { using handle = openHandle(); }</script>import { open } from "node:fs/promises";import { setTimeout as delay } from "node:timers/promises"; await using file = await open("report.txt", "r");const text = await file.readFile("utf8"); using timer = setTimeout(() => console.log("too slow"), 5000);await delay(10);console.log(text.length);Modern Node file handles expose [Symbol.asyncDispose](), and timer handles expose [Symbol.dispose](). The important version boundary is parsing: Node 22 has Symbol.dispose and Symbol.asyncDispose, but it does not parse using. Node 24+ does.
// TypeScript 5.2+ accepts this syntax.{ using stack = new DisposableStack(); const file = stack.use(openFile()); stack.defer(() => console.log("always runs"));} // For older targets, TypeScript emits helper code with try/finally// and calls Symbol.dispose / Symbol.asyncDispose for you.Common misconceptions and comparisons
- “Garbage collection will close it.” Garbage collection frees memory. It does not promise to release file descriptors, locks, subscriptions, timers, or blob URLs at the moment you need.
- “FinalizationRegistry is a cleanup replacement.” It is a backup notification mechanism. Its callback timing is deliberately not guaranteed. Review WeakRef & FinalizationRegistry for the non-deterministic model.
- “
usingcatches errors.” It does not catch. It cleans up while control leaves the scope, then the return or throw continues. - “The first resource closes first.” Cleanup is reverse declaration or registration order.
- “If
Symbol.disposeexists, syntax is safe.” Node 22 proves otherwise: the symbols exist, but the parser rejectsusing. - “This is brand new in programming.” The idea is familiar from C#
usingand Pythonwith; JavaScript's version uses well-known symbols and separate sync/async protocols.
const registry = new FinalizationRegistry((id) => { console.log("maybe later", id);}); function cacheImage(image) { registry.register(image, image.id); return new WeakRef(image);} // You cannot predict when, or whether, the callback runs.| Question | using / stacks | FinalizationRegistry | C# using / Python with |
|---|---|---|---|
| When does cleanup run? | At lexical scope exit, deterministically. | Sometime after garbage collection, if at all. | At scope/context exit, deterministically. |
| What method is called? | [Symbol.dispose]() or [Symbol.asyncDispose](). | The registry callback receives a held value. | Dispose() in C#, __exit__ / __aexit__ in Python. |
| What is it for? | Files, locks, timers, listeners, object URLs, transactions. | Best-effort cleanup hints and diagnostics. | The same scarce-resource problem in those languages. |
The symbol names also connect back to Symbols and well-known symbols: JavaScript uses built-in symbols as opt-in hooks for language behavior.
Practice exercises
5 EXERCISESRead the code and type the exact text printed by the final line.
const log = [];
function resource(name) {
return { [Symbol.dispose]() { log.push(`dispose ${name}`); } };
}
{
using one = resource("one");
using two = resource("two");
log.push("body");
}
console.log(log.join(" -> "));The block body logs body. Then two disposes, then one, so the output is body -> dispose two -> dispose one.
Which property name makes the object work with using?
const handle = {
// Write the well-known symbol method here.
};[Symbol.dispose]() { close(); }A synchronous disposable object implements a method at [Symbol.dispose].
Which symbol belongs in the computed method name?
const connection = {
async [/* which symbol goes here? */]() {
await closeConnection();
},
};async [Symbol.asyncDispose]() { await closeConnection(); }The async disposer may return a promise, and await using waits for it.
Which message prints first when the stack is disposed?
const stack = new DisposableStack();
stack.defer(() => console.log("first registered"));
stack.defer(() => console.log("second registered"));
stack.dispose();DisposableStack runs the most recently registered cleanup first, so second registered prints before first registered.
A preview creates a blob URL for a selected file. What call belongs in the disposer?
function showPreview(file, preview) {
const url = URL.createObjectURL(file);
preview.src = url;
// What cleanup call belongs in the disposer?
}URL.revokeObjectURL(url);Revoking the URL releases the browser-managed blob URL when the preview no longer needs it.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which problem is explicit resource management designed to solve?
Choose an answer to see the explanation.
Question 2 of 7What does this
usingblock print?Read the code, then predictconst log = []; function resource(name) { return { [Symbol.dispose]() { log.push(`dispose ${name}`); } }; } { using one = resource("one"); using two = resource("two"); log.push("body"); } console.log(log.join(" -> "));Choose an answer to see the explanation.
Question 3 of 7What happens when a
usingvalue isnullorundefined?Choose an answer to see the explanation.
Question 4 of 7Which method does
await usingawait at block exit?Choose an answer to see the explanation.
Question 5 of 7What does this stack print first when disposed?
Read the code, then predictconst stack = new DisposableStack(); stack.defer(() => console.log("first registered")); stack.defer(() => console.log("second registered")); stack.dispose();Choose an answer to see the explanation.
Question 6 of 7When does JavaScript create a
SuppressedError?Choose an answer to see the explanation.
Question 7 of 7Which support statement is accurate today?
Choose an answer to see the explanation.
Key takeaways
usingcalls[Symbol.dispose]()at scope exit.await usingawaits[Symbol.asyncDispose]()at scope exit.- Cleanup runs in reverse declaration or registration order, even on throw or early return.
DisposableStackandAsyncDisposableStackgroup scattered cleanup under one owner.SuppressedErrorpreserves both a cleanup failure and an original failure.- Use FinalizationRegistry only as a non-deterministic backup, not as resource cleanup.
Remember the one-liner.
Own every scarce resource in the smallest scope that can release it, and let the scope perform the release reliably.
This is the last lesson in Binary data & memory and the Advanced language features stage. Up next: Professional JavaScript begins with npm and package.json.