I promise you shouldn't fear rejection
This post was originally posted on the Campfire Learning blog.
Pop quiz for node.js users — what does this program output if the promise resolves, and what is different if it fails to reject through its else
branch?
async function main() {
const promise = new Promise((resolve) => {
setTimeout(() => {
if (Math.random() > 0.5) {
console.log("We win");
resolve();
} else {
console.log("We lose");
}
});
});
await promise.catch(console.error);
console.log("Looks like we made it");
}
main();
The success case is easy to reason about; the await promise
statement will wait for the setTimeout
to finish, and then with the promise resolved the console.log
will occur and then with no tasks left to keep the event loop alive the process will exit with an exit code of 0.
$ node index.js
We win
Looks like we made it
Now, what should be done about the other case in which the promise finishes its setTimeout
but neither resolves nor rejects? My initial instinct was that the runtime would likely hang forever since the promise does not fulfill its contract to either resolve or reject.
You might also think that since the promise has no more outstanding tasks, it might also yield back to the await
statement but neither call its .then()
nor its .catch()
handlers, since it didn't do either of the specified behaviors.
Neither of these are the actual behavior of Node's runtime, however. Instead, as soon as the setTimeout
callback finishes in the "We lose" branch and fails to resolve or reject, the node.js runtime detects that no more tasks are alive and declares event loop bankruptcy and immediately exits with return code 0.
$ node index.js
We lose
This is, to put it bluntly, not at all what I was expecting. It is possible to get a tiny bit more warning if you do a top-level await
instead of calling an async
method from the root JS context, like so:
await promise.catch(console.error);
console.log("Looks like we made it");
$ node index.js
We lose
Warning: Detected unsettled top-level await at file:///Users/smerrill/Projects/async-fun/index.js:12
await promise.catch(console.error);
^
In any case, I imagine that a lot of JS developers will be using some kind of CLI framework or some other tool and may find their JS processes simply exiting right in the middle of them when they did not expect it. (Indeed, we found this in our @oclif
-based CLI tooling when a process just stopped partway through but didn't throw an error of any kind.)
So what can you do about this?
In a long GitHub issue in the node.js
project several folks mentioned ensuring that your Promises or asynchronous functions are always doing work of some kind, and also ensuring that they always properly either resolve
or reject
when they are finished.
Similarly, if you do find this kind of issue, it can be quite hard to debug; you could look for asynchronous code that is doing something that looks like this where a finished promise is not guaranteed to properly reject
if cleanup fails:
const promise = new Promise((resolve, reject) => {
// Do something with a stream of data
stream.on("end", resolve);
stream.on("error", (err) => {
try {
cleanup();
reject(err);
} catch (e) {}
});
});
This behavior has also surprised folks using the node-archiver
library who forgot to call its .finalize()
method.
Finally, since debugging asynchronous runtimes can be quite difficult, you might want to
use the --inspect-brk
flag to get a better visualization of what your program is doing in Flamegraph format.
Good luck, and may your event loops always stay active until your work has finished!
- Previous: Why Is TypeScript Picking Up Old Types?