
PR-URL: https://github.com/nodejs/node/pull/58526 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: Gerhard Stöbich <deb2001-github@yahoo.de> Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: LiviaMedeiros <livia@cirno.name>
18 KiB
Explicit Resource Management (using
) Guidelines
Explicit Resource Management is a capability that was introduced to the JavaScript language in 2025. It provides a way of marking objects as disposable resources such that the JavaScript engine will automatically invoke disposal methods when the object is no longer in scope. For example:
class MyResource {
dispose() {
console.log('Resource disposed');
}
[Symbol.dispose]() {
this.dispose();
}
}
{
using resource = new MyResource();
// When this block exits, the `Symbol.dispose` method will be called
// automatically by the JavaScript engine.
}
This document outlines some specific guidelines for using explicit resource management in the Node.js project -- specifically, guidelines around how to make objects disposable and how to introduce the new capabilities into existing APIs.
The caveat to this guidance is that explicit resource management is a brand new language feature, and there is not an existing body of experience to draw from when writing these guidelines. The points outlined here are based on the current understanding of how the mechanism works and how it is expected to be used. As such, these guidelines may change over time as more experience is gained with explicit resource management in Node.js and the ecosystem. It is always a good idea to check the latest version of this document, and more importantly, to suggest changes to it based on evolving understanding, needs, and experience.
Some background
Objects can be made disposable by implementing either, or both, the
Symbol.dispose
and Symbol.asyncDispose
methods:
class MySyncResource {
[Symbol.dispose]() {
// Synchronous disposal logic
}
}
class MyAsyncDisposableResource {
async [Symbol.asyncDispose]() {
// Asynchronous disposal logic
}
}
An object that implements Symbol.dispose
can be used with the using
statement, which will automatically call the Symbol.dispose
method when the
object goes out of scope. If an object implements Symbol.asyncDispose
, it can
be used with the await using
statement in an asynchronous context. It is
worth noting here that await using
means the disposal is asynchronous,
not the initialization.
{
using resource = new MyResource();
await using asyncResource = new MyResource();
}
Importantly, it is necessary to understand that the design of using
makes it
possible for user code to call the Symbol.dispose
or Symbol.asyncDispose
methods directly, outside of the using
or await using
statements. These
can also be called multiple times and by any code that is holding a reference
to the object. That is to say, explicit resource management does not imply
ownership of the object. It is not a form of RAII (Resource Acquisition Is
Initialization) as seen in some other languages and there is no notion of
exclusive ownership of the object. A disposable object can become disposed
at any time.
The Symbol.dispose
and Symbol.asyncDispose
methods are called in both
successful and exceptional exits from the scopes in which the using
keyword
is used. This means that if an exception is thrown within the scope, the
disposal methods will still be called (similar to how finally { }
blocks work).
However, when the disposal methods are called they are not aware of the context.
These methods will not receive any information about any exception that may have
been thrown. This means that it is often safest to assume that the disposal
methods will be called in a context where the object may not be in a valid
state or that an exception may be pending.
Guidelines for disposable objects
So with this in mind, it is necessary to outline some guidelines for disposers:
- Disposers should be idempotent. Multiple calls to the disposal methods should not cause any issues or have any additional side effects.
- Disposers should assume that they are being called in an exception context.
Always assume there is likely a pending exception and that if the object
has not been explicitly closed when the disposal method is called, the
object should be disposed as if an exception had occurred. For instance,
if the object API exposes both a
close()
method and anabort()
method, the disposal method should callabort()
if the object is not already closed. If there is no difference in disposing in success or exception contexts, then separate disposal methods are unnecessary. - It is recommended to avoid throwing errors within disposers.
If a disposer throws an exception while there is another pending
exception, then both exceptions will be wrapped in a
SuppressedError
that masks both. This makes it difficult to understand the context in which the exceptions were thrown. - Disposable objects should expose named disposal methods in addition
to the
Symbol.dispose
andSymbol.asyncDispose
methods. This allows user code to explicitly dispose of the object without using theusing
orawait using
statements. For example, a disposable object might expose aclose()
method that can be called to dispose of the object. TheSymbol.dispose
andSymbol.asyncDispose
methods should then invoke these named disposal methods in an idempotent manner. - Because it is safest to assume that the disposal method will be called
in an exception context, it is generally recommended to prefer use of
Symbol.dispose
overSymbol.asyncDispose
when possible. Asynchronous disposal can lead to delaying the handling of exceptions and can make it difficult to reason about the state of the object while the disposal is in progress. Disposal in an exception context is preferably synchronous and immediate. That said, for some types of objects async disposal is not avoidable. - Asynchronous disposers, by definition, are able to yield to other tasks
while waiting for their disposal task(s) to complete. This means that, as a
minimum, a
Symbol.asyncDispose
method must be anasync
function, and mustawait
at least one asynchronous disposal task. If either of these criteria is not met, then the disposer is actually a synchronous disposer in disguise, and will block the execution thread until it returns; such a disposer should instead be defined usingSymbol.dispose
. - Because the disposal process is strictly ordered, there is an intrinsic expectation that all tasks performed by a single disposer are fully complete at the point that the disposer returns. This means, for example, that "callback-style" APIs must not be invoked within a disposer, unless they are promisified and awaited. Any Promise created within a disposer must be awaited, to ensure its resolution prior to the disposer returning.
- Avoid, as much as possible, using both
Symbol.dispose
andSymbol.asyncDispose
in the same object. This can make it difficult to reason about which method will be called in a given context and could lead to unexpected behavior or subtle bugs. This is not a firm rule, however; there may be specific cases where it makes sense to define both, such as where a resource already exposes both synchronous and asynchronous methods for closing down the resource.
Example disposable objects
A disposable object can be quite simple:
class MyResource {
#disposed = false;
dispose() {
if (this.#disposed) return;
this.#disposed = true;
console.log('Resource disposed');
}
[Symbol.dispose]() {
this.dispose();
}
}
{ using myDisposable = new MyResource(); }
Or even fully anonymous objects:
function getDisposable() {
let disposed = false;
return {
dispose() {
if (disposed) return;
disposed = true;
console.log('Resource disposed');
},
[Symbol.dispose]() {
this.dispose();
},
};
}
{ using myDisposable = getDisposable(); }
Some disposable objects, however, may need to differentiate between disposal in a success context and disposal in an exception context as in the following example:
class MyDisposableResource {
constructor() {
this.closed = false;
}
doSomething() {
if (maybeShouldThrow()) {
throw new Error('Something went wrong');
}
}
close() {
// Gracefully close the resource.
if (this.closed) return;
this.closed = true;
console.log('Resource closed');
}
abort(maybeError) {
// Abort the resource, optionally with an exception. Calling this
// method multiple times should not cause any issues or additional
// side effects.
if (this.closed) return;
this.closed = true;
if (maybeError) {
console.error('Resource aborted due to error:', maybeError);
} else {
console.log('Resource aborted');
}
}
[Symbol.dispose]() {
// Note that when this is called, we cannot pass any pending
// exceptions to the abort method because we do not know if
// there is a pending exception or not.
this.abort();
}
}
Then in use:
{
using resource = new MyDisposableResource();
// Do something with the resource that might throw an error
resource.doSomething();
// Explicitly close the resource if no error was thrown to
// avoid the resource being aborted when the `Symbol.dispose`
// method is called.
resource.close();
}
Here, if an error is thrown in the doSomething()
method, the Symbol.dispose
method will still be called when the block exits, ensuring that the resource is
disposed of properly using the abort()
method. If no error is thrown, the
close()
method is called explicitly to gracefully close the resource. When the
block exits, the Symbol.dispose
method is still called but it will be a non-op
since the resource has already been closed.
To deal with errors that may occur during disposal, it is necessary to wrap the disposal block in a try-catch:
try {
using resource = new MyDisposableResource();
// Do something with the resource that might throw an error
resource.doSomething();
resource.close();
} catch (error) {
// Error might be the actual error thrown in the block, or might
// be a SuppressedError if an error was thrown during disposal and
// there was a pending exception already.
if (error instanceof SuppressedError) {
console.error('An error occurred during disposal masking pending error:',
error.error, error.suppressed);
} else {
console.error('An error occurred:', error);
}
}
Symbol.dispose and Symbol.asyncDispose return values
The Symbol.dispose
method should return undefined
and the
Symbol.asyncDispose
method should return a Promise
that resolves to
undefined
.
[Symbol.dispose]() {
return void this.dispose();
// or
this.dispose();
// or
return;
// or
// no return
}
async [Symbol.asyncDispose]() {
await this.dispose();
// or
return;
// or
// no return
}
Debuggability of disposer methods
To improve debugging experience, The Symbol.dispose
and Symbol.asyncDispose
functions should not be direct aliases of named disposer functions. They should
instead defer to the named disposer. This ensures the stack traces can be more
informative about whether a disposer was called via using
or was called
directly.
For example:
// Do something like this:
function dispose() { ... }
return {
dispose,
[Symbol.dispose]() { this.dispose(); }
};
// Rather than this:
function dispose() { ... }
return {
dispose,
[Symbol.dispose]: dispose
};
A Note on documenting disposable objects
When documenting disposable objects, it is important to clearly indicate that
the object is disposable and how it should be disposed of. This includes
documenting the Symbol.dispose
and Symbol.asyncDispose
methods, as well as
any named disposal methods that the object exposes.
If the disposable object is anonymous (that is, it is a regular JavaScript
object that implements the Symbol.dispose
method), it is still important to
document that it is disposable and how it should be disposed of.
Within the documentation, it is possible to document anonymous objects as if
they were classes, using the Class:
prefix and otherwise presenting the
object as if you were documenting a regular JavaScript class, even if it is
never actually instantiated as a class. Examples of this pattern can be seen,
for instance, in the documentation of the Web Crypto API.
So, for example, if you have an API that returns an anonymous disposable object, you might document it like:
### Class: `MyDisposableObject`
#### `myDisposableObject.dispose()`
...
#### `myDisposableObject[Symbol.dispose]()`
...
### `foo.getMyDisposableObject()`
* Returns: {MyDisposableObject}
Guidelines for introducing explicit resource management into existing APIs
Introducing the ability to use using
into existing APIs can be tricky.
The best way to understand the issues is to look at a real world example. PR
58516 is a good case. This PR
sought to introduce Symbol.dispose
and Symbol.asyncDispose
capabilities
into the fs.mkdtemp
API such that a temporary directory could be created and
be automatically disposed of when the scope in which it was created exited.
However, the existing implementation of the fs.mkdtemp
API returns a string
value that cannot be made disposable. There are also sync, callback, and
promise-based variations of the existing API that further complicate the
situation.
In the initial proposal, the fs.mkdtemp
API was changed to return an object
that implements the Symbol.dispose
method but only if a specific option is
provided. This would mean that the return value of the API would become
polymorphic, returning different types based on how it was called. This adds
a lot of complexity to the API and makes it difficult to reason about the
return value. It also makes it difficult to programmatically detect whether
the version of the API being used supports using
or not.
fs.mkdtemp('...', { disposable: true })
would act differently in older versions
of Node.js than in newer versions with no way to detect this at runtime other
than to inspect the return value.
Some APIs that already return objects that can be made disposable do not have
this kind of issue. For example, the setImmediate()
API in Node.js returns an
object that implements the Symbol.dispose
method. This change was made without
much fanfare because the return value of the API was already an object.
So, some APIs can be made disposable easily without any issues while others require more thought and consideration. The following guidelines can help when introducing these capabilities into existing APIs:
- Avoid polymorphic return values: If an API already returns a value that can be made disposable, and it makes sense to make it disposable, do so. Do not, however, make the return value polymorphic determined by an option passed into the API.
- Introduce new API variants that are
using
capable: If an existing API cannot be made disposable without changing the return type or making it polymorphic, consider introducing a new API variant. For example,fs.mkdtempDisposable
could be introduced to return a disposable object while the existingfs.mkdtemp
API continues to return a string. Yes, it means more APIs to maintain but it avoids the complexity and confusion of polymorphic return values. If adding a new API variant is not ideal, remember that changing the return type of an existing API is quite likely a breaking change. - When an existing API signature does not lend itself easily to supporting making the return value disposable and a new API needs to be introduced, it is worth considering whether the existing API should be deprecated in favor of the new. Deprecation is never a decision to be taken lightly, however, as it can have major ecosystem impact.
Guidelines for using disposable objects
Because disposable objects can be disposed of at any time, it is important to be careful when using them. Here are some guidelines for using disposables:
- Never use
using
orawait using
with disposable objects that you do not own. For instance, the following code is problematic if you are not the owner ofsomeObject
:
function foo(someObject) {
using resource = someObject;
}
The reason this is problematic is that the using
statement will
unconditionally call the Symbol.dispose
method on someObject
when the block
exits, but you do not control the lifecycle of someObject
. If someObject
is disposed of, it may lead to unexpected behavior in the rest of the
code that called the foo
function.
- When there is a clear difference between disposing of an object in a success context vs. an exception context, always explicitly dispose of objects the successful code paths, including early returns. For example:
class MyDisposableResource {
close() {
console.log('Resource closed');
}
abort() {
console.log('Resource aborted');
}
[Symbol.dispose]() {
// Assume the error case here...
this.abort();
}
}
function foo() {
using res = new MyDisposableResource();
if (someCondition) {
// Early return, ensure the resource is disposed of
res.close();
return;
}
// do other stuff
res.close();
}
This is because of the fact that, when the disposer is called, it has no way of knowing if there is a pending exception or not and it is generally safest to assume that it is being called in an exceptional state.
Many types of disposable objects make no differentiation between success and
exception cases, in which case relying entirely on using
is just fine (and
preferred). The disposable returned by setImmediate()
is a good example here.
All that does is call clearImmediate()
and it does not matter if the block
errored or not.
- Remember that disposers are invoked in a stack, in the reverse order in which they were created. For example,
class MyDisposable {
constructor(name) {
this.name = name;
}
[Symbol.dispose]() {
console.log(`Disposing ${this.name}`);
}
}
{
using a = new MyDisposable('A');
using b = new MyDisposable('B');
using c = new MyDisposable('C');
// When this block exits, the disposal methods will be called in the
// reverse order: C, B, A.
}
Because of this, it is important to consider the possible relationships between disposable objects. For example, if one disposable object holds a reference to another disposable object the cleanup order may be important.