Explicit resource management
Learn using, await using, DisposableStack, and SuppressedError to clean up files, locks, listeners, timers, and object URLs reliably.
- 01Clean up resources at a known momentCreate variables with
usingso their[Symbol.dispose]()method runs however the block ends. - 02Wait for cleanup that takes timeUse
await usingand[Symbol.asyncDispose]()when closing a resource returns a promise. - 03Group cleanup and read cleanup errorsUse
DisposableStackandAsyncDisposableStack, and read aSuppressedErrorwithout losing the first error.
Why cleanup needs help
JavaScript frees memory for you. When no code can reach an object any more, the garbage collector removes it. You don't know when that happens.
Some things need more than freed memory. An open file must be closed. A lock must be released, so other code can take it. A click listener must be removed. The garbage collector does none of this at the moment you are done.
These things are called resources. Your code holds a small object for each one, called a handle. The handle lets you use the resource and then give it back. Giving it back is called disposing of it, or cleanup.
The usual tool for cleanup is try/finally. The code in finally runs however the try block ends. It works, but with several resources the cleanup code gets long and easy to get wrong.
Explicit resource management is a JavaScript feature that cleans up resources at a known moment. You write using instead of const to create the variable. JavaScript then ties the resource to its scope: the block of code between { and } where the variable exists. When the code leaves that block, JavaScript calls the resource's cleanup method, [Symbol.dispose](). For cleanup that takes time, you write await using, and JavaScript waits for [Symbol.asyncDispose]() to finish.
[Symbol.dispose]() method on the way out. A normal end, a return, and a throw all lead there.These are common resources that need cleanup:
- open files
- locks
- database connections
- subscriptions to updates
- timers
- short-lived event listeners
- blob URLs made with URL.createObjectURL()
You borrow equipment from a photo studio. First you pick up a camera. Then a tripod. Then a visitor badge. When you leave the building, you return everything at the desk. The badge goes back first, then the tripod, then the camera. The studio has only a few of each, so they must come back on time.
- In real life: Borrow a camera, a tripod, and a badge
- In JavaScript: Open a file, take a lock, and add a listener
- In real life: Leave the building
- In JavaScript: Leave the block where the variables exist
- In real life: Return the newest item first
- In JavaScript: Clean up the last resource first
const events = [];
try {
events.push("work");
throw new Error("failed");
} catch (error) {
events.push(error.message);
} finally {
events.push("close");
}
console.log(events.join(" -> "));Line 3 records work. Line 4 throws an error. The catch block records the message, failed. Then the finally block records close. The output is work -> failed -> close.
try/finally is still useful. Use it in older browsers, or for cleanup that doesn't belong to one object.
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 11 opens the file. Line 12 opens the lock. Lines 15 and 16 close them again, in the opposite order: the lock first, then the file. You must remember to write both lines. Line 19 prints open file -> open lock -> work -> close lock -> close file.
Add a third resource, an if, or an early return, and the cleanup code starts to hide the real work. There is one more problem. If lock.close() throws, line 16 never runs, so the file stays open. To be sure both close, you need one try/finally inside another.
const events = [];
function openSecond() { throw new Error("second open failed"); }
try {
using first = { [Symbol.dispose]() { events.push("close first"); } };
using second = openSecond();
} catch (error) {
events.push(error.message);
}
console.log(events.join(" -> "));Line 4 creates first with using, so JavaScript already knows to clean it up. Line 5 tries to open a second resource, but openSecond() throws. JavaScript cleans up first before the catch runs. The output is close first -> second open failed.
The lesson: hand each resource to using as soon as you get it. Don't wait until you have all of them.
The table compares four cleanup tools. Read each row as: what the tool is good for, and what it costs.
| Tool | When it fits best | What to watch out for |
|---|---|---|
| try/finally | It works everywhere and can do any kind of cleanup. | With several resources it gets long. You must write the reverse order yourself, and a missing finally leaks. |
| using | One or more resources that belong to one block. | The runtime must understand using, or TypeScript must rewrite it. The value must have a [Symbol.dispose]() method. |
| DisposableStack | Resources you get in different places, or only sometimes. | Cleanup still runs at a known moment. But you must add each cleanup to the stack, and clean up the stack itself. |
| FinalizationRegistry | A backup message after the garbage collector removes an object. | It may run late or never. It can't replace closing files, locks, sockets, blob URLs, or listeners. |
using and Symbol.dispose
STEP THROUGHAn object can be cleaned up by using when it has a method named [Symbol.dispose]. Symbol.dispose is a built-in symbol: a special, unique property key that JavaScript itself knows about. The square brackets mean "use this symbol as the method name".
The line using file = openFile() does two things:
- It creates the variable
file, which you can use inside the block. - It tells JavaScript to call
file[Symbol.dispose]()when the code leaves the block.
The cleanup runs even if the block throws an error or returns early. With two using lines, the one written last is cleaned up first. Step through the code below. Pick each of the three ways out, and watch the order of the events.
Edit & run opens the code in a separate browser sandbox. These examples need a browser that understands using: Chrome 134+, Edge 134+, or Firefox 141+. Safari's normal release and Node 22 stop with an error before any code runs. The feature check later in this lesson tests this page, not the editor sandbox. If your browser doesn't support using, read the try/finally examples, or let TypeScript rewrite the code for you.
const events = [];
{
using maybeLock = null;
using lock = { [Symbol.dispose]() { events.push("unlock"); } };
events.push("work");
}
console.log(events.join(" -> "));Line 3 sets maybeLock to null, so there is nothing to clean up. JavaScript simply skips it. Line 4 creates a real lock. When the block ends, the lock is cleaned up, so the code prints work -> unlock.
So a resource that might be missing is fine. A normal object with no cleanup method is not; you'll see why below.
Step through the end of the block. Change how it ends to see that cleanup runs after a normal end, an early return, and an error.
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(" -> "));file is acquired first, then lock. On the way out the order flips: lock closes first, and file closes last.null and undefined are allowed. JavaScript skips them. Any other value must have a [Symbol.dispose] method. A plain object without one causes a TypeError on the using line itself.
{ 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);}Lines 2 and 3 are allowed and do nothing. Line 4 creates an object with a cleanup method, so it prints disposed handle when the block ends.
Line 13 is the mistake. {} is not null or undefined, and it has no cleanup method. JavaScript throws a TypeError instead of guessing. Lines 16 and 17 print true and TypeError.
let saved;
{
using handle = {
closed: false,
[Symbol.dispose]() { this.closed = true; },
};
saved = handle;
}
console.log(saved.closed);Line 7 saves the handle in saved, a variable outside the block. That copies the reference, but the block still owns the resource. The block closes the handle when it ends, so line 9 prints true.
Don't return or save a handle from a using block. Return the data you read from it instead.
await using waits for cleanup that takes time
ASYNCSome cleanup takes time. Closing a database connection, saving the last data in a stream, or undoing a half-finished database change all return a promise: an object that finishes later.
For these resources, give the object an [Symbol.asyncDispose]() method and create the variable with await using. The block does not finish until each of these cleanup promises has finished.
(async () => {
const events = [];
{
await using lock = { [Symbol.dispose]() { events.push("unlock"); } };
events.push("work");
}
console.log(events.join(" -> "));
})();The lock on line 4 has only [Symbol.dispose], the normal cleanup method. That's fine: when there is no [Symbol.asyncDispose], await using uses [Symbol.dispose] instead. It still pauses for a moment when the block ends. The output is work -> unlock.
The other way round doesn't work. A plain using can't use an object that only has [Symbol.asyncDispose].
await using is for resources whose cleanup returns a promise. Step through and watch JavaScript wait for each cleanup, newest first.
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 last log on line 20 is outside the block on purpose. It runs only after the stream has closed and then the database has closed. The order is still reversed: the stream was opened last, so it closes first.
(async () => {
const events = [];
async function open() {
events.push("open");
return { async [Symbol.asyncDispose]() { events.push("close"); } };
}
{
await using connection = await open();
events.push("work");
}
console.log(events.join(" -> "));
})();Line 8 has two waits, at two different times:
await open()waits right away, until the connection is open.await usingwaits later, at the end of the block, until the cleanup is done.
The output is open -> work -> close.
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(" -> "));Line 18 is for (using row of rows()). Each turn of the loop gets a new row variable, created with using. The row is cleaned up at the end of its turn, before the loop reads the next row. Line 22 prints read A -> close A -> read B -> close B.
This helps when only one resource may be open at a time, such as one page of database results.
DisposableStack and AsyncDisposableStack
STACKSSometimes a block gets its resources from different places. One helper function creates a URL. Another takes a lock. A third adds a callback, but only if a setting is on. Writing a using line for each one is hard.
A DisposableStack collects all the cleanup in one place. Each time you get a resource, you add its cleanup to the stack. At the end, you clean up the stack once, and it cleans up everything in it.
const events = [];
{
using stack = new DisposableStack();
stack.defer(() => events.push("remove listener"));
events.push("listening");
}
console.log(events.join(" -> "));Line 3 creates the stack with using, so the stack is cleaned up when the block ends. Line 4 adds one cleanup callback right away. The output is listening -> remove listener.
A stack does not clean itself up. Create it with using, or call its dispose() method yourself.
Step through a stack that uses 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(" -> "));dispose() takes from the top, so the last one added runs first.A stack has a few methods and one property:
use(value)adds an object that already has a[Symbol.dispose]method.adopt(value, onDispose)adds a plain value, plus a function that cleans it up.defer(fn)adds a cleanup function on its own.move()moves all the cleanup to a new stack, and marks the old stack as done.dispose()runs all the cleanup, last added first.disposedistrueonce the stack has been cleaned up.
const stack = new DisposableStack();
let closes = 0;
stack.defer(() => closes++);
stack.dispose();
stack.dispose();
console.log(closes, stack.disposed);Lines 4 and 5 both call dispose(), but the callback runs only once. The second call does nothing. Line 6 prints 1 true.
When you open a new handle, add it to the stack in the same line: stack.use(open()). Then no code can run between opening the handle and adding it.
(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 some cleanup returns a promise. Create it with await using on line 4, so JavaScript waits for the whole stack when the block ends. Lines 5, 6, and 9 add three cleanups. Line 10 records inside. When the block ends, the three cleanups run in reverse order. Line 12 prints inside -> rollback transaction -> close socket -> defer metrics.
`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 its cleanup is run. 'Cleanup at a known moment' includes try/finally and a direct using. 'Cleanup through a stack' means a DisposableStack holds the cleanup.
SuppressedError: when cleanup fails too
REAL OUTPUTCleanup can fail too. Closing a file can throw an error. Undoing a database change can fail. Removing a listener can hit a browser error.
Sometimes the block is already ending because of an error, and then the cleanup throws a second error. JavaScript doesn't drop either one. It throws a new error, a SuppressedError, that holds both.
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);}Line 8 throws work failed. As the block ends, the cleanup method on line 4 runs and throws cleanup failed. The catch on line 10 receives one error that holds both:
- Line 11 prints
true: the caught error is aSuppressedError. - Line 12 prints
cleanup failed: theerrorproperty holds the cleanup failure. - Line 13 prints
work failed: thesuppressedproperty holds the earlier work failure.
error keeps both failures. error.error is the cleanup failure, and error.suppressed is the work failure from line 8.You practiced the same habit in custom errors and error handling strategies: keep every error that explains what went wrong, instead of replacing one with another.
try {
using first = { [Symbol.dispose]() { throw Error("first close"); } };
using second = { [Symbol.dispose]() { throw Error("second close"); } };
throw Error("work");
} catch (error) {
console.log(error.error.message);
console.log(error.suppressed.error.message);
console.log(error.suppressed.suppressed.message);
}Line 4 throws work. Then second is cleaned up first, and it throws. first is still cleaned up after that, and it throws too. Each failure wraps the one before it. Lines 6 to 8 print first close, second close, and then work.
To find an earlier error, follow the suppressed property one step at a time. When you report the error, keep all of them.
Practical patterns in apps
BROWSERA good pattern is a small helper function that returns an object with a cleanup method. The helper sets the resource up and also knows how to clean it up. The code that calls the helper can't pair the wrong cleanup with the wrong setup.
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");Line 1 defines listen(). It adds a listener on line 2. Its cleanup method on line 4 removes the same listener on line 5.
Line 14 adds a click listener with using. The click on line 15 logs clicked, because the listener is still there. When the block ends, the listener is removed. So the click on line 18 logs nothing, and line 19 prints listener removed.
Use this for any listener you need only for a short time, such as while the user drags something across the page.
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:"));}URL.createObjectURL() on line 2 makes a blob: URL that points at data in memory, such as a picked photo. The browser keeps that data until you call URL.revokeObjectURL() or the page closes. If you forget, the data stays in memory the whole time.
The cleanup method on line 5 revokes the URL on line 6. Line 13 creates the preview with using, and line 14 prints true. Then the block ends, and the code prints revoked object URL.
In a real app, revoke a preview URL when the preview is replaced or removed. Don't let a short function revoke it while an image on the page still shows it. This example only logs the URL. It doesn't run in the editor sandbox, because a blob URL belongs to the page that created it.
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();}A transaction is a group of database changes that either all happen or none do. Closing one takes time, so this example uses await using on line 12.
- If
insertthrows, the transaction is still open, so the cleanup on line 6 undoes it withrollback(). - If
commit()on line 14 succeeds, the transaction is closed, so the cleanup does nothing.
Where using works, and TypeScript
CHECK FIRSTAccording to MDN's browser data, these support explicit resource management:
- Chrome and Edge 134 and later
- Firefox 141 and later
- Node.js 24 and later
- Safari Technology Preview, Apple's test version of Safari
That doesn't mean every visitor's browser supports it. Check the runtime, the program that really runs your code, such as a browser or Node. For older runtimes, let TypeScript rewrite the code into older JavaScript.
function supportsUsingSyntax() { try { new Function("{ using x = null; }"); return true; } catch { return false; }} console.log(supportsUsingSyntax());Not checked yetThe check runs in your browser, because Node 22 can load this lesson but can't read using.
using block. If it throws, the browser can't read using. Checking for Symbol.dispose isn't enough, because Node 22 has the symbol but can't read the keyword.The first table lists what supports the feature. Read each row as: where, which versions, and what to remember.
| Where | Which versions | What to remember |
|---|---|---|
| Browsers | Chrome and Edge 134+, Firefox 141+, Safari Technology Preview | MDN marks the feature as not yet in every browser, because Safari's normal release doesn't have it yet. |
| Node.js | Node 24+ understands using. Node 22 doesn't. | In modern Node, a FileHandle has [Symbol.asyncDispose]() and a timer has [Symbol.dispose](). |
| TypeScript | TypeScript 5.2+ | It accepts using and await using, and can rewrite them into older JavaScript. |
| Feature check | Try to read the code | Run new Function("{ using x = null; }") inside a try block before you offer using code that runs. |
The second table lists where you may write using. Read each row as: the place, whether it works, and an example.
| Where you write it | Does it work? | Example |
|---|---|---|
| Inside a block or a function | Yes | { using handle = open(); } or function f() { using handle = open(); } |
| At the top level of a module | Yes | A module has its own scope, so using works at its top level. |
| At the top level of a classic script | No, it's a SyntaxError | Put it inside a block, or use <script type="module">. |
| In a for...of loop | Yes | for (using row of rows()) cleans up each row before the next turn of the loop. |
<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>A classic script is a plain <script> tag. Line 3 puts using at its top level, outside any block, so it fails. Line 8 is the same line in a module script, <script type="module">, and it works. Line 14 puts the line inside { }, which works in a classic script too.
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);In modern Node, a file handle has an [Symbol.asyncDispose]() method, so line 4 uses await using. A timer handle has a [Symbol.dispose]() method, so line 7 uses using.
Watch the version. Node 22 already has Symbol.dispose and Symbol.asyncDispose, but it can't read the using keyword. Node 24 and later can.
// 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.TypeScript can rewrite using into older code with try/finally. The rewritten code still needs Symbol.dispose or Symbol.asyncDispose to exist when it runs. If you use DisposableStack, the runtime needs that too, or a polyfill: a small script that adds a missing feature. Check the runtime as well as the TypeScript version.
Common misconceptions and comparisons
- “The garbage collector will close it.” The garbage collector frees memory. It doesn't promise to close files, release locks, stop timers, remove listeners, or revoke blob URLs when you need it to.
- “FinalizationRegistry can do the cleanup.” A
FinalizationRegistrycalls a function some time after an object is removed from memory. It may run late, or never. Use it only as a backup. See WeakRef & FinalizationRegistry. - “
usingcatches errors.” It doesn't. It cleans up as the code leaves the block. Then thereturnor the error carries on as normal. - “The first resource closes first.” It closes last. The resource added last is cleaned up first.
- “If
Symbol.disposeexists, I can writeusing.” Not always. Node 22 has the symbol, but it can't read theusingkeyword. - “No other language does this.” C# has
usingand Python haswithfor the same job. JavaScript's version uses built-in symbols, with one method for normal cleanup and one for cleanup that takes time.
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.Line 1 creates the registry. Line 6 asks it to call the function after image is removed from memory. As line 10 says, you can't know when that happens, or whether it happens at all.
The table compares three ways to clean up. Read each column as one tool, and each row as one question.
| Question | using and stacks | FinalizationRegistry | C# using and Python with |
|---|---|---|---|
| When does cleanup run? | Right when the code leaves the block. | Some time after the object leaves memory, or never. | Right when the code leaves the block. |
| What gets called? | [Symbol.dispose]() or [Symbol.asyncDispose](). | The function you gave the registry. | Dispose() in C#. __exit__ or __aexit__ in Python. |
| What is it for? | Files, locks, timers, listeners, blob URLs, and transactions. | Backup cleanup and debugging help. | The same kind of resources, in those languages. |
The method names come from Symbols and well-known symbols. JavaScript uses built-in symbols to let your objects take part in language features.
Practice exercises
5 EXERCISESRead the code. Type the exact text that the last line prints.
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 pushes body first. When the block ends, two is cleaned up, then one. So the last line prints body -> dispose two -> dispose one.
What method name makes this object work with using?
const handle = {
// Write the well-known symbol method here.
};Add [Symbol.dispose]() { close(); } inside the object. With that method, the object works with using.
Which symbol goes inside the square brackets?
const connection = {
async [/* which symbol goes here? */]() {
await closeConnection();
},
};Write async [Symbol.asyncDispose]() { await closeConnection(); } inside the object. When the block ends, await using waits for this method's promise to finish.
When the stack is cleaned up, which message prints first?
const stack = new DisposableStack();
stack.defer(() => console.log("first registered"));
stack.defer(() => console.log("second registered"));
stack.dispose();DisposableStack runs the cleanup added last first. So second registered prints before first registered.
A preview makes a blob URL for a file the user picked. What call belongs in the cleanup method?
function showPreview(file, preview) {
const url = URL.createObjectURL(file);
preview.src = url;
// What cleanup call belongs in the disposer?
}Call URL.revokeObjectURL(url) when the preview is replaced or removed. Pass the URL you made for that preview, not the file.
Check your understanding
7 QUESTIONSQuestion 1 of 7What problem does explicit resource management 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 usingtry first when the block ends?Choose an answer to see the explanation.
Question 5 of 7When this stack is cleaned up, what prints first?
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 statement about support is true today?
Choose an answer to see the explanation.
Key takeaways
usingcalls[Symbol.dispose]()when the code leaves the block.await usingwaits for[Symbol.asyncDispose]()when the code leaves the block.- The resource created last is cleaned up first, even after an error or an early
return. DisposableStackandAsyncDisposableStackcollect cleanup from many places in one spot.SuppressedErrorkeeps both the cleanup error and the error that came before it.FinalizationRegistrymay run late or never, so use it only as a backup.
Remember the one-liner.
Give each resource to the smallest block that can clean it up, and let the block do the cleanup for you.
This is the last lesson in Binary data & memory, and in the Advanced language features stage. Next, Professional JavaScript starts with npm and package.json.