JavaScript using declarations: automatic cleanup without finally
Learn JavaScript's using and await using declarations, Symbol.dispose and DisposableStack, with tested browser recipes and October 2026 support data.
8 min read

In this article
JavaScript's using declaration runs a cleanup method automatically when a block ends, whether it ends normally, with return, or with an exception. You give an object a [Symbol.dispose]() method, declare it with using instead of const, and the language calls that method for you. It replaces many try...finally blocks, and it works today in Chrome, Edge and Firefox, but not yet in a released version of Safari.
This tutorial shows how the feature behaves, how to write disposable objects for everyday browser code (event listeners, timers, observers and abortable work), when to reach for DisposableStack, and how to adopt it safely while Safari catches up. Every example was run on Node.js 24, which ships the same V8 implementation as Chrome.
Why cleanup code goes wrong
Front-end code acquires plenty of things that must be released: event listeners, intervals, MutationObserver and ResizeObserver instances, AbortControllers, object URLs, locks and stream readers. The traditional pattern is try...finally:
const id = setInterval(poll, 5000);
try {
await runWizard();
} finally {
clearInterval(id);
}That works for one resource. With three, you nest three try blocks, or you write one finally that has to know which resources were actually acquired before the error. Cleanup also ends up far away from acquisition, so it is easy to forget when someone edits the code later. using keeps acquisition and cleanup on the same line.
The basics: Symbol.dispose and using
An object is disposable when it has a method under the well-known symbol Symbol.dispose. Here is a small wrapper that removes an event listener when it goes out of scope:
class TempListener {
constructor(target, type, handler) {
this.target = target;
this.type = type;
this.handler = handler;
target.addEventListener(type, handler);
}
[Symbol.dispose]() {
this.target.removeEventListener(this.type, this.handler);
}
}
const target = new EventTarget();
{
using listener = new TempListener(target, 'ping', () => console.log('ping received'));
target.dispatchEvent(new Event('ping')); // "ping received"
} // listener removed here
target.dispatchEvent(new Event('ping')); // nothing happensA using variable behaves like const: it must be initialised, cannot be reassigned and is block-scoped. The rules MDN documents are worth knowing before you start:
- Reverse order. With several declarations in one scope, disposers run in the reverse order of declaration, like unwinding a stack.
- Always runs. Disposal happens on every exit path, exactly like a
finallyblock. - Null is allowed. The value may be
nullorundefined, which makes optional resources easy. Any other value without a callable[Symbol.dispose]throws aTypeErrorat the declaration. - No destructuring.
using { a, b } = objis a syntax error; declare the whole object. - Where it is allowed. Inside blocks, function bodies, class static blocks, at the top level of a module and in
for,for...ofandfor await...ofinitialisers. It is not allowed at the top level of a classic script, at the top level of aswitchstatement, or in afor...inloop.
const res = (name) => ({ [Symbol.dispose]() { console.log(`dispose ${name}`); } });
{
using a = res('a');
using b = res('b');
console.log('body');
}
// body
// dispose b
// dispose a
Recipes for browser code
A timer that cannot leak
function timeout(ms, callback) {
const id = setTimeout(callback, ms);
return { [Symbol.dispose]() { clearTimeout(id); } };
}
function showToastWhileSaving(save) {
using slowHint = timeout(800, () => showToast('Still saving...'));
return save(); // fast saves never show the toast
}If save() throws synchronously, the timer is still cleared. Note that for an async save() you need to await inside the block; otherwise the block ends as soon as the promise is returned.
Observers that disconnect themselves
function observe(element, callback) {
const observer = new ResizeObserver(callback);
observer.observe(element);
return { [Symbol.dispose]: () => observer.disconnect() };
}Loops where each item owns a resource
for (using item of openItems()) {
process(item);
} // each item is disposed at the end of its own iterationawait using for asynchronous cleanup
Some cleanup is asynchronous: flushing a write queue, closing a connection, releasing a lock held by a server. For those, implement [Symbol.asyncDispose]() and declare the variable with await using inside an async function or a module:
async function openSession() {
const session = await api.startSession();
return {
session,
async [Symbol.asyncDispose]() {
await api.endSession(session.id);
},
};
}
async function exportReport() {
await using s = await openSession();
return await s.session.download('report.csv');
} // endSession() is awaited before exportReport() settlesTwo details from MDN catch people out. First, await using falls back to [Symbol.dispose] when there is no async disposer, but a promise returned by that sync method is not awaited. Second, a scope with an await using declaration always awaits at least once on exit, even when the value is null, so the timing of the function does not change depending on whether the resource existed.
DisposableStack: many resources, one owner
using covers resources you declare one by one. When you acquire a dynamic number of them, or need to hand ownership to someone else, use DisposableStack (or AsyncDisposableStack):
| Method | Use it for |
|---|---|
use(value) | Register an object that already has [Symbol.dispose]. |
adopt(value, onDispose) | Register something that is not disposable, with your own cleanup function. |
defer(onDispose) | Register a plain callback. |
move() | Transfer every registered disposer to a new stack and mark this one as disposed. |
dispose() | Run all disposers in reverse order of registration. |
The classic case is a setup function: if step three fails, steps one and two must be undone, but if everything succeeds the caller becomes responsible for cleanup.
function mountWidget(element) {
using stack = new DisposableStack();
const controller = stack.adopt(new AbortController(), (c) => c.abort());
element.addEventListener('click', onClick, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });
const url = URL.createObjectURL(new Blob(['...']));
stack.defer(() => URL.revokeObjectURL(url));
renderInto(element, url); // if this throws, everything above is undone
return stack.move(); // success: hand ownership to the caller
}
const widget = mountWidget(document.querySelector('#chart'));
// later, for example when the user navigates away:
widget.dispose();Because move() leaves the original stack marked as disposed, the using stack declaration does nothing on the success path. MDN also recommends wrapping the acquisition expression directly in use() or adopt(), as above, so no code can run between acquiring a resource and registering it.

When cleanup itself fails: SuppressedError
If the block throws and a disposer also throws, you would lose one of the two errors with a hand-written finally. using keeps both in a SuppressedError: the earlier error goes in suppressed, the later one in error.
try {
using r = { [Symbol.dispose]() { throw new Error('cleanup failed'); } };
throw new Error('work failed');
} catch (e) {
console.log(e.name); // "SuppressedError"
console.log(e.error.message); // "cleanup failed"
console.log(e.suppressed.message); // "work failed"
}Make sure your error reporting reads both properties; a logger that only prints message will show "An error was suppressed during disposal" and hide the real cause.
Browser support and adopting it today
According to MDN's browser compatibility data (version 8.1.5, 8 October 2026), using, await using, DisposableStack, AsyncDisposableStack and SuppressedError are supported from:
| Engine | First version |
|---|---|
| Chrome and Edge | 134 |
| Firefox | 141 |
| Safari (macOS) | Technology Preview only |
| Safari on iOS | Not supported |
| Node.js | 24.0.0 |
On the standards side, TC39's list of finished proposals includes Explicit Resource Management with an expected publication year of 2027, so the semantics are settled even though one engine has not shipped yet.
The Symbol.dispose protocol and DisposableStack are ordinary built-ins and can be polyfilled. The using keyword is new syntax: a browser that does not understand it rejects the whole script file, so a polyfill cannot help. Your options are:
- Write disposable objects now, call them manually. Implement
[Symbol.dispose]()on your helpers (guarding withSymbol.dispose ??= Symbol('Symbol.dispose')or a polyfill) and callobj[Symbol.dispose]()fromfinally. You can switch tousinglater without changing the helpers. - Compile the syntax. TypeScript and Babel can transform
usingintotry...finallycode for older targets. - Use it natively where you control the runtime, for example in Chrome-only internal tools, browser extensions for Chromium and Firefox, or Node.js 24 build scripts.
Gotchas
- Closures outlive the block. A callback that captures a
usingvariable may run after disposal and work with a closed resource. Pass ownership withDisposableStack.move()instead. - Do not export disposed values.
export usingis a syntax error, and exporting a module-levelusingvariable gives importers a resource that is already disposed. - Forgetting
awaitin the block.usingdisposes when the block ends, not when a promise you returned settles. - Classic scripts. Top-level
usingworks only in modules (<script type="module">).
What to do next
- Pick one helper that has a matching "stop" or "close" function (a subscription, a timer, an observer) and give it a
[Symbol.dispose]()method. - Replace a multi-resource setup function with
DisposableStackandmove(). - Check your analytics for Safari share before shipping uncompiled
usingsyntax to the public web.
The payoff is code where every acquisition visibly owns its cleanup, and where a failure halfway through setup no longer leaves listeners, timers and object URLs behind.
Frequently asked questions
Is using the same as const?
It has the same scoping and reassignment rules as
const, plus one extra behaviour: when the block ends, the value's[Symbol.dispose]()method is called. The value must benull,undefinedor an object with that method.Which browsers support using in October 2026?
Chrome and Edge 134 and later, and Firefox 141 and later, according to MDN's compatibility data. Safari supports it only in Technology Preview, and Safari on iOS does not support it.
Can I polyfill the using keyword?
No. It is syntax, so older engines fail to parse the file. You can polyfill
Symbol.disposeandDisposableStack, and compileusingwith TypeScript or Babel.What happens if a dispose method throws?
All disposers still run. If more than one error occurs, they are combined into a
SuppressedErrorwhoseerrorproperty holds the later error and whosesuppressedproperty holds the earlier one.When should I use await using instead of using?
When cleanup is asynchronous and must finish before the function continues, such as closing a session on a server. Implement
[Symbol.asyncDispose]()and declare the variable withawait usinginside an async function or a module.Can I use using at the top level of a normal script tag?
No. Top-level
usingis only allowed in modules. Inside a classic script, put it in a block or a function body.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- using — MDN Web Docs
- await using — MDN Web Docs
- DisposableStack — MDN Web Docs
- ECMAScript Explicit Resource Management proposal — TC39
- Finished Proposals — TC39
- MDN browser-compat-data 8.1.5 (data.json) — MDN Web Docs, 2026-10-08
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Architecture guideJavaScript
Ship JavaScript without a bundler: import maps and JSON modules
How to structure a front end as native ES modules: import maps for bare imports, JSON modules, modulepreload against waterfalls, and cache-busting.
By Cloudsoft Zone

ComparisonJavaScript
structuredClone vs JSON vs spread: deep copy JavaScript safely
Spread is shallow, JSON loses dates, Maps, Sets and undefined, and structuredClone keeps them. A tested comparison of JavaScript copy methods and their limits.
By Cloudsoft Zone

Troubleshooting guideJavaScript
Fix 'Uncaught (in promise)': find every unhandled rejection
Why browsers log Uncaught (in promise), the five code patterns behind most unhandled rejections, how to find them in DevTools, and a safe global handler.
By Cloudsoft Zone
