Cancellation and resource disposal are two tightly related concepts that risk being confused in JavaScript. Cancellation is about aborting in-flight work as quickly as possible while disposal is about cleaning up after yourself. There are some key distinctions between the two that will be made clear in this article.

Photo by Noor Sethi on Unsplash
Basic example of a main() function
Here is the basic pattern that I have landed on that seems to handle both cancellation and disposal.
import { once } from 'node:events/promises';
async function main() {
const processCtl = new AbortController();
// Set up handlers on `process` to handle events that should
// cause our program to immediately cancel everything and
// then initiate shut-down. These `wireXYZ()` functions will call
// `.cancel()` on the `processCtl` when their respective events fire.
wireUncaughtExceptions(processCtl);
wireUnhandledRejections(processCtl);
wireExitSignals(processCtl);
// Initialize prerequisites for starting our server...
// Create a new AbortSignal that will abort at the earliest of:
// 1. processCtl.signal aborting; and
// 2. 1000 ms passing
const serverStartSignal = createChildSignalWithTimeout(
processCtl.signal,
1000
);
// The `startServer` function accepts an `AbortSignal`. This allows us to
// give it a deadline for starting up. It returns an `AsyncDisposable`
// object. We've used the `await using` syntax which will cause `server`
// to be disposed (async) in FIFO order as soon as control leaves `main()`.
await using server = await startServer(config, {
signal: serverStartSignal
});
// We awaited something (server start) so we need to check if our program
// aborted. We need to do this after every `await`.
processCtl.signal.throwIfAborted();
// Convert the AbortSignal into a Promise. This prevents `main` from
// completing until the server is stopped.
await once(processCtl.signal, 'abort');
}
main().catch(err => {
console.trace(err);
process.exit(1);
});
As you can see in this example, we’ve got a main() function that is designed to encapsulate the whole lifecycle of our program. It is an async function so when its Promise settles, our program is done. If it rejects, we’ll exit with a non-zero code.
The magic is the interaction between the AbortSignals and our use of the using keyword from Explicit Resource Management. We use AbortSignal to synchronously inform us when we need to abort in-flight operations and start cleaning up.
But how do we actually clean up? That’s where the using keyword comes in (specifically the async async using variant). When we leverage explicit resource management, we’re asking the JavaScript runtime to call the Symbol.asyncDispose method of server (and wait for the returned Promise to settle) at the moment control would otherwise have left main().
Example of an async factory
Our first example was great for the main() function of our program. It works very well for us because we explicitly don’t want main() to finish until our program finishes.
So what about startServer()? In that case, we want to start a server and then return something. Starting the server might be a multi-step process but we definitely don’t want to be cleaning up the server right away. We want to return it.
Here’s the second key pattern for factory functions:
import { createServer } from "node:http";
import { once } from "node:events/promises";
export async function startServer(signal) {
// We'll ask the runtime to dispose of this object as soon as control leaves
// this function. We'll register anything we need to clean up with the stack
// instead of the runtime. We can later move these clean-up instructions out
// of the managed stack into a new one!
await using disposer = new AsyncDisposableStack();
const server = createServer(requestHandler);
// We now have a server reference so let's let the disposer know that it needs
// to be cleaned up. The AsyncDisposableStack has methods for wiring up non
// Disposable objects.
disposer.defer(
() =>
new Promise((resolve, reject) => {
if (!server.listening) resolve(undefined);
server.close((err) => {
if (err) {
return reject(err);
}
return resolve(undefined);
});
})
);
const listeningPromise = once(server, "listening");
const abortedPromise = once(signal, "abort");
// Actually ask the server to start listening.
server.listen(port, hostname);
await Promise.race([listeningPromise, abortedPromise]);
// We called await, so we should check if the signal was aborted.
signal.throwIfAborted();
// Here's the magic where we empty out the disposer and move the entries into
// a new disposable object that we'll return. In this way the caller can now
// manage the timing of the clean-up of the server.
const serverDisposer = disposer.move();
return {
[Symbol.disposeAsync]() {
return serverDisposer[Symbol.disposeAsync]();
},
};
}
As you can see, the high-level pattern for an async factory is:
- Create and register an AsyncDisposableStack with the await using keyword.
- Register any clean-up logic with that stack as soon as we have a reference to the resource.
- Check if things are aborted and throw after every async operation.
- Once everything is done, we .move() all of the disposal instructions into a new AsyncDisposableStack owned by whatever we return to the caller.
- The caller registers the disposable resource according to its lifecycle.
Does it scale?
This pattern covers the main() function of our app and any async resource creation. The patterns described here can be nested according to use-case and should scale with the size of the project.
Adopting these patterns are simple. Here are the rules:
- Thread AbortSignals through the layers of your app. They are used for cancelling in-flight work and unwinding the stack when it’s time to shut down. Wire up key process events to cancel your root AbortController.
- Call signal.throwIfAborted() after every meaningful async call. Every time we yield to the event loop, the signal could have become aborted. We need to convert those events into thrown exceptions to trigger clean-up.
- Leverage Explicit Resource Management to register Disposable and AsyncDisposable resources as soon as you have a reference to them.
- Accumulate disposable resources in a temporary AsyncDisposableStack during creation. Call .move() to disconnect them from the function’s lifecycle and return an AsyncDisposable that will dispose the cloned disposable stack.
Originally published on Medium.