JavaScript's "destructors" or the explicit resource management
Published on

The limitations of the JavaScript's current resource management
JavaScript is a language with a garbage collection (GC). This means if any value becomes inaccessible it will be eventually deallocated from the memory. This implicit cleanup mechanism works well for a lot of cases and developers shouldn't worry about memory leaks as long as they don't leave references on no longer used resources.
However, this works well only if it's about deallocating the objects from the JS memory heap. Some resources, such as open file handles, also require additional custom logic for the resource deallocation, such as closing the open file handles. This is especially important in NodeJS.
Nowadays, in order to do this reliably, experienced developers usually write something like this:
let fileHandle;
try {
fileHandle = await fs.open('some_file_path', 'r');
// Do something
// ...
} finally {
// Wrapping in finally guarantees that the
// file handle will eventually close
// even in a case of an error.
filehandle?.close();
}
Why doesn't JavaScript support destructors?
As you see, this construct looks bulky. Also notice that fileHandle
should be declared outside the
try
block in order to be accessible from the finally
block. So why doesn't JavaScript
support destructors which will cleanup the code eventually and
allow us to avoid writing such boilerplate code?
There are some possible reasons for this:
- It's now too late to interpret a function with a name "destructor" or any other name as a
destructor, because it will
break the backward compatibility. The only acceptable way is to add some symbol, such as
Symbol.destructor
. - Even if we add a symbol for the destructor, the destructor will not be called immediately, since it takes
time for the garbage collector to determine which objects are not reachable. This will not work very
predictably and consistently across different JavaScript engines. Also, JavaScript already supports
WeakRef
andFinalizationRegistry
which are not recommended to be used even by Mozilla because of the mentioned reason. - Garbage collectors will have to somehow handle exceptions, infinite loops, stuck code and deadlocks in the destructor. This is unrealistic.
In short, forget about destructors in JavaScript.
Explicit resource management
In order to solve this problem a proposal was created for explicit resource management.
Basically, the feature adds keyword using
and 2 new symbols: Symbol.dispose
and
Symbol.asyncDispose
.
How using, Symbol.dispose and Symbol.asyncDispose work
Suppose you have some resource class called DisposableResource
and you want the cleanup method to be
called automatically when the resource variable is no longer in the block scope. The simple synchronous case
can be written like this:
class DisposableResource {
constructor() {
console.log('The resource is created');
}
[Symbol.dispose]() {
console.log('The resource is disposed');
}
}
{
// Keyword "using" behaves like "const", but also tells the compiler to dispose
// the object immediately after the declared variable becomes unreachable.
using resource = new DisposableResource();
console.log('Doing something with the resource...');
}
// After the end the resource block scope [Symbol.dispose]() is called.
[Symbol.dispose]()
will be called even when an uncaught error is thrown:
class DisposableResource {
constructor() {
console.log('The resource is created');
}
[Symbol.dispose]() {
console.log('The resource is disposed');
}
}
{
using resource = new DisposableResource();
throw "Error";
}
// [Symbol.dispose]() will be called even when an uncaught error occurs.
The resources are disposed in the reverse order of their declaration to ensure that the older resources are available when disposing the newer resources which might depend on the older resources:
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
}
}
{
using resourceA = new DisposableResource('A');
using resourceB = new DisposableResource('B');
console.log('Doing something with the resources...');
}
// The resource B is disposed
// The resource A is disposed
If an uncaught exception occurs during the disposal(s), SuppressedError
is thrown.
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
throw `Error occurred during the disposal of ${this.#name}`;
}
}
{
using resourceA = new DisposableResource('A');
using resourceB = new DisposableResource('B');
}
// SuppressedError
Asynchronous disposal
The disposal can also be asynchronous if it's used in an asynchronous function or the top level context. In this
case we need
to use keyword await using
and Symbol.asyncDispose
:
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
}
async [Symbol.asyncDispose]() {
console.log(`The resource ${this.#name} is disposed asynchronously`);
}
}
{
using resourceA = new DisposableResource('A');
using resourceB = new DisposableResource('B');
await using resourceC = new DisposableResource('C');
await using resourceD = new DisposableResource('D');
console.log('Doing something with the resources...');
}
// The resource D is disposed asynchronously
// The resource C is disposed asynchronously
// The resource B is disposed
// The resource A is disposed
If [Symbol.asyncDispose]
is missing, [Symbol.dispose]
will be called instead:
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
}
}
{
using resourceA = new DisposableResource('A');
using resourceB = new DisposableResource('B');
await using resourceC = new DisposableResource('C');
await using resourceD = new DisposableResource('D');
console.log('Doing something with the resources...');
}
// The resource D is disposed
// The resource C is disposed
// The resource B is disposed
// The resource A is disposed
Disposal in for loops
JavaScript also adds a nice syntax for using disposal for each item in the loop, here are examples for all combinations of sync/async loops and sync/async disposals:
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
}
async [Symbol.asyncDispose]() {
console.log(`The resource ${this.#name} is disposed asynchronously`);
}
}
function * syncGeneratorSyncDisposal() {
yield new DisposableResource('A');
yield new DisposableResource('B');
}
function * syncGeneratorAsyncDisposal() {
yield new DisposableResource('C');
yield new DisposableResource('D');
}
async function * asyncGeneratorSyncDisposal() {
yield new DisposableResource('E');
yield new DisposableResource('F');
}
async function * asyncGeneratorAsyncDisposal() {
yield new DisposableResource('G');
yield new DisposableResource('H');
}
for (using resource of syncGeneratorSyncDisposal()) {
// Do something with the resource
}
// The resource A is created
// The resource A is disposed
// The resource B is created
// The resource B is disposed
for (await using resource of syncGeneratorAsyncDisposal()) {
// Do something with the resource
}
// The resource C is created
// The resource C is disposed asynchronously
// The resource D is created
// The resource D is disposed asynchronously
for await (using resource of asyncGeneratorSyncDisposal()) {
// Do something with the resource
}
// The resource E is created
// The resource E is disposed
// The resource F is created
// The resource F is disposed
for await (await using resource of asyncGeneratorAsyncDisposal()) {
// Do something with the resource
}
// The resource G is created
// The resource G is disposed asynchronously
// The resource H is created
// The resource H is disposed asynchronously
DisposableStack and AsyncDisposableStack
As a bonus we have 2 built-in classes that help us to manage the resources: DisposableStack
and
AsyncDisposableStack
. They have the following main methods and properties:
disposed
. A getter that returns a boolean indicating whether the stack has been disposed.dispose()
(DisposableStack
only). Disposes the stack.disposeAsync()
(AsyncDisposableStack
only). Disposes the stack asynchronously, returns a promise.use(value)
. Adds a disposable resource to the top of the stack. Has no effect if providednull
orundefined
.adopt(value, onDispose)
. Adds a non disposable resource to the top of the stack and an associated callback. The callback will be called with thevalue
as an argument when the stack is disposed.defer(onDispose)
. Adds a callback which is called after the stack is disposed.move()
. Returns a new disposable stack with the added items and empties the current stack.
DisposableStack
and AsyncDisposableStack
are useful for grouping the resources into a
single object. When they're disposed they dispose the added resources in the reverse order.
Here are some examples:
class DisposableResource {
#name;
constructor(name) {
this.#name = name;
console.log(`The resource ${this.#name} is created`);
}
[Symbol.dispose]() {
console.log(`The resource ${this.#name} is disposed`);
}
async [Symbol.asyncDispose]() {
console.log(`The resource ${this.#name} is disposed asynchronously`);
}
}
{
using syncStack = new DisposableStack();
await using asyncStack = new AsyncDisposableStack();
syncStack.use(new DisposableResource('A'));
syncStack.use(new DisposableResource('B'));
asyncStack.use(new DisposableResource('C'));
asyncStack.use(new DisposableResource('D'));
}
console.log('All resources are disposed');
// The resource A is created
// The resource B is created
// The resource C is created
// The resource D is created
// The resource D is disposed asynchronously
// The resource C is disposed asynchronously
// The resource B is disposed
// The resource A is disposed
// All resources are disposed
Conclusion
Explicit resource management is a very nice feature to have, especially considering that JavaScript cannot support destructors. It allows to write clean and reliable code for resource cleanups. Currently this feature is not supported across all the mainstream browsers, so use it with caution. This feature is very useful for NodeJS because on the server side you often deal with resources that need some logic for cleanups, such as files and locks. Starting from NodeJS 24 you can use this feature with caution. Some APIs already have this experimental feature integrated, such as file handles. Chrome 134+ also support this feature.