cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Explicit resource management

Learn using, await using, DisposableStack, and SuppressedError to clean up files, locks, listeners, timers, and object URLs reliably.

By the end, you can
  • 01
    Release resources deterministicallyUse using declarations and [Symbol.dispose]() to clean up handles on every block exit path.
  • 02
    Handle async cleanupChoose await using and [Symbol.asyncDispose]() when closing a resource returns a promise.
  • 03
    Group and debug cleanupApply DisposableStack, AsyncDisposableStack, and SuppressedError without 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.

Definition

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
Real-life analogyA checkout desk for borrowed equipment

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.

Manual cleanup with try/finallyPop out in the code editor (opens in a new tab)JavaScript
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.

Choosing a cleanup tool
ToolBest useTrade-off
try/finallyWorks everywhere and can express any cleanup.Gets noisy with several resources, reverse order is manual, and a missed finally leaks.
usingBest for one or more block-scoped synchronous resources.Needs native parser support or TypeScript downleveling, and the value must have [Symbol.dispose]().
DisposableStackBest when resources are acquired conditionally or inside helper branches.Still deterministic, but you must register each cleanup and dispose or using the stack.
FinalizationRegistryUseful for backup notifications after garbage collection.Not deterministic. It cannot replace closing files, locks, sockets, object URLs, or listeners.

using and Symbol.dispose

STEP THROUGH

A 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.

using cleanup order lab
Step 0 of 14Ready
Your turn: follow the blue line

Step through the block exit. Change the mode to prove cleanup happens on normal completion, early return, and thrown errors.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 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(" -> "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Choose how the block exits

The cleanup order stays the same. The only change is whether the caller sees a normal result, an early return value, or an error.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
The exact contract

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.

Nullish values are skipped; plain objects failPop out in the code editor (opens in a new tab)JavaScript
{  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

ASYNC

Some 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 waits for cleanup
Step 0 of 12Ready
Your turn: follow the blue line

await using is for resources whose cleanup itself returns a promise. Step through the awaited reverse-order cleanup.

Running in
  1. script
Next: line 2
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
(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(" -> "));})();
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

A disposable binding in a for...of loopPop out in the code editor (opens in a new tab)JavaScript
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

STACKS

A 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.

DisposableStack ownership lab
Step 0 of 10Ready
Your turn: follow the blue line

Step through a stack that combines defer, use, adopt, move, dispose, and disposed.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" -> "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

AsyncDisposableStack mirrors the same ideaPop out in the code editor (opens in a new tab)JavaScript
(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.

Which cleanup bucket?
  • `using lock = acquireLock()`
  • `await using db = await connect()`
  • `stack.defer(() => revoke(url))`
  • `new FinalizationRegistry(cleanup)`
  • `finally { file.close(); }`
  • `stack.use(fileHandle)`
Try it yourself
0 of 6 correct

Sort each card by how cleanup is controlled. Deterministic includes try/finally and direct using; stack-managed means a DisposableStack owns the callbacks.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

SuppressedError: when cleanup fails too

REAL OUTPUT

Cleanup 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.

A cleanup error suppresses the original errorPop out in the code editor (opens in a new tab)JavaScript
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

BROWSER

The 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.

A temporary event listener helperPop out in the code editor (opens in a new tab)JavaScript
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.

An object URL wrapper for previewsJavaScript
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.

A transaction wrapper uses async disposalJavaScript
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 FIRST

MDN 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.

Feature check: can this runtime parse using?
Parser feature detectionPop out in the code editor (opens in a new tab)JavaScript
function supportsUsingSyntax() {  try {    new Function("{ using x = null; }");    return true;  } catch {    return false;  }} console.log(supportsUsingSyntax());
Current browserNot checked yet
ResultNot checked yet
Try it yourself

The check runs in the browser because Node 22 can load this lesson but cannot parse using syntax.

A parser check is more accurate than checking for Symbol.dispose alone, because Node 22 has the symbols but rejects the syntax.
Support verified for this lesson
AreaStatusWhat to remember
BrowsersChrome and Edge 134+, Firefox 141+, Safari Technology PreviewMDN marks explicit resource management as limited availability because stable Safari support is still pending.
Node.jsNode 24+ parses using; Node 22 does notNode's FileHandle exposes [Symbol.asyncDispose]() and timer handles expose [Symbol.dispose]() in the modern line.
TypeScriptTypeScript 5.2+The compiler accepts using / await using and can downlevel the cleanup machinery to helper code for older targets.
Feature detectionParser checkUse new Function("{ using x = null; }") inside a try block before offering runnable using code.
Where the parser accepts using
LocationAllowed?Example
Block or function bodyAllowed{ using handle = open(); } or function f() { using handle = open(); }
Module top levelAllowedA module has its own lexical scope, so top-level using is valid there.
Classic script top levelSyntaxErrorWrap it in a block or use <script type="module">.
Loop declarationAllowedfor (using row of rows()) disposes each iteration's binding before the next iteration.
Classic scripts need a block or moduleHTML
<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>
Node 24+ disposable file and timer handlesJavaScript
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+ can downlevel usingTypeScript
// 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.
  • “using catches 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.dispose exists, syntax is safe.” Node 22 proves otherwise: the symbols exist, but the parser rejects using.
  • “This is brand new in programming.” The idea is familiar from C# using and Python with; JavaScript's version uses well-known symbols and separate sync/async protocols.
Finalizers are not deterministic cleanupJavaScript
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.
Similar ideas, different guarantees
Questionusing / stacksFinalizationRegistryC# 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 EXERCISES
Exercise 1 · Warm-upPredict reverse disposal

Read the code and type the exact text printed by the final line.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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(" -> "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the synchronous protocol

    Which property name makes the object work with using?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const handle = {
      // Write the well-known symbol method here.
    };

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeName the async protocol

      Which symbol belongs in the computed method name?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const connection = {
        async [/* which symbol goes here? */]() {
          await closeConnection();
        },
      };

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead stack cleanup order

        Which message prints first when the stack is disposed?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const stack = new DisposableStack();
        stack.defer(() => console.log("first registered"));
        stack.defer(() => console.log("second registered"));
        stack.dispose();

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeApply it to an image preview

          A preview creates a blob URL for a selected file. What call belongs in the disposer?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function showPreview(file, preview) {
            const url = URL.createObjectURL(file);
            preview.src = url;
            // What cleanup call belongs in the disposer?
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which problem is explicit resource management designed to solve?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this using block print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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(" -> "));

              Choose an answer to see the explanation.

            3. Question 3 of 7What happens when a using value is null or undefined?

              Choose an answer to see the explanation.

            4. Question 4 of 7Which method does await using await at block exit?

              Choose an answer to see the explanation.

            5. Question 5 of 7What does this stack print first when disposed?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const stack = new DisposableStack();
              stack.defer(() => console.log("first registered"));
              stack.defer(() => console.log("second registered"));
              stack.dispose();

              Choose an answer to see the explanation.

            6. Question 6 of 7When does JavaScript create a SuppressedError?

              Choose an answer to see the explanation.

            7. Question 7 of 7Which support statement is accurate today?

              Choose an answer to see the explanation.

            Key takeaways

            • using calls [Symbol.dispose]() at scope exit.
            • await using awaits [Symbol.asyncDispose]() at scope exit.
            • Cleanup runs in reverse declaration or registration order, even on throw or early return.
            • DisposableStack and AsyncDisposableStack group scattered cleanup under one owner.
            • SuppressedError preserves 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.

            CompleteFrontend Clear concepts. Working examples.