Promises are the basic building block of asynchronous JavaScript, to the point where we end up believing them to be infallible. They are, however, far less so than we think, and they’re the source of plenty of bugs when poorly mastered. Among all their shortcomings, one remains largely unknown to the general public even though it can have serious repercussions in production: resource management.
I won’t cover every problem tied to Promises here, there would be enough material for an entire series. Let’s focus on a single one, too often ignored: what happens to the resources mobilized by an asynchronous operation once that operation no longer has any reason to keep going.
Promises don’t know how to interrupt themselves
This is the root of the problem: by their very nature, Promises are not interruptible. Once launched, a Promise runs to the end of its execution, full stop. There’s no built-in mechanism to tell it “stop, we no longer need your result” and, above all, to guarantee that the resources it mobilized will be properly released.
Promise.all and Promise.race are the most telling examples of functions liable to cause memory leaks precisely because of this limitation.
The case of Promise.race
Take Promise.race, whose purpose is to put several operations in competition. The fastest one wins the race and earns the right to return its result; that’s the only result we get back.
But what becomes of the operations that lost? That’s the whole trap: they keep executing regardless, because they’re never interrupted. The result is ignored, but the work itself runs to completion.
Now imagine that one of these losing operations is holding a socket or database connection, a file descriptor, a timer, or any other critical resource. We have absolutely no guarantee that these resources will be released. Worse still, as long as these operations are running, they keep the event loop busy and can prevent the Node.js process from terminating cleanly.
The following example illustrates exactly this behavior, and the contrast with an approach capable of interrupting the losers.
import { setTimeout } from "node:timers/promises";
/**
* Promise.race doesn't allow interrupting the losers, so the Node.js process
* will stay alive until the 10s timeout has finished.
* This also has the consequence of tying up memory and resources
* at the event loop level even though the timer no longer matters: it lost
* the race.
*/
const winnerWithLosersRunningStill = Promise.race([
setTimeout(1000).then(() => console.log("1s done")),
// Will keep running in the background
setTimeout(10000).then(() => console.log("10s done")),
]);
import { Duration, Effect, pipe } from "effect";
const logDelay = (duration: Duration.Duration) =>
pipe(Effect.log(duration.toString()), Effect.delay(duration));
/**
* Effect.race automatically interrupts the losers thanks to the interruptible
* property of Effects by default. No resource is wasted and the
* program terminates cleanly, which lets the Node.js process exit
* correctly.
*/
const winnerEndingTheRace = pipe(
// Finish line crossed, the program is done
logDelay(Duration.seconds(1)),
// Automatically interrupted after 1s
Effect.race(logDelay(Duration.seconds(10))),
Effect.runFork
);
In the first half, the 10-second timer lost the race the moment the first one finished, but it keeps living inside the event loop, delaying the end of the process and consuming resources for a result nobody wants anymore.
Writing resource-safe code with Promises
How, then, do we write code that respects the resource lifecycle despite this limitation? There are two main approaches available today.
AbortController: effective but tedious
The web platform provides the AbortController API, which lets you create a signal (AbortSignal) that can be propagated across a chain of operations. This signal serves to request the interruption of asynchronous operations: each link in the chain has to listen for it and react accordingly.
The solution is effective, but it remains tedious to implement. You quickly find yourself doing signal drilling: manually passing the signal from function to function, through every layer of the program, so that it’s accessible where the cancellable operation actually lives. For non-trivial programs, writing composable and maintainable code with this kind of API gets complicated very fast.
Effect: interruption as a native property
The other approach is to lean on primitives more powerful than Promises. Effects, via a library like Effect, natively support interruptions. An Effect is interruptible by default, and the runtime takes care of guaranteeing the proper release of resources when one or more operations are cancelled.
This is exactly what the second half of the example shows: Effect.race automatically interrupts the losers. No resource is wasted, the program terminates cleanly, and the Node.js process can stop as expected, where Promise.race left it hanging.
Effect’s decisive advantage lies in how simply you can define interruption models and react to them, regardless of the program’s complexity. Resource management stays linear with respect to the program’s complexity, whereas with AbortController it tends to grow much faster as the code gets bigger. And this observation holds, more broadly, for many other aspects of concurrency management with Effect.
Conclusion
The problem isn’t that Promises are bad, they remain an indispensable tool. The problem is that they were never designed to model interruption and the resource lifecycle. As long as an asynchronous operation can’t be stopped, you can’t guarantee that what it opened will be closed.
AbortController provides a functional but ergonomically costly answer. Effects, on the other hand, treat interruption as a first-class property, which makes resource-safe code not only possible, but composable and maintainable. The moment your programs mobilize critical resources and operations can lose their race, this is a distinction that deserves your full attention.