Top-level promise handling in Node.js ES modules
- Published at
- Updated at
- Reading time
- 4min
I was writing a quick Node.js script the other day, made a silly mistake, and faced the exit code 13
. There was no error. I just looked at this status code I haven't seen before.
TLDR: the exit code 13
is used when an ESM-based Node.js script exits before the top-level code is resolved.
The script used ESM-based top-level await and performed some API requests to transform the data and write it to disk.
I needed a wait
function (don't judge!) and made a silly mistake.
Here's a very simplified version of the script:
// index.mjs (ESM)
const wait = (time) =>
new Promise((resolve) => {
setTimeout(() => resolve, time);
});
console.log("waiting");
await wait(1000);
console.log("done");
// -----
// Exit code: 13
Can you spot the mistake?
The wait
function returns a new promise which automatically resolves after a given timeframe using setTimeout
. But I didn't execute resolve
, so this promise will never resolve.
Pro tip: Node 15+ supports async timers, so there's no need for custom wait
functions.
There's been an obvious bug in my code, but shouldn't Node just hang forever in this case?
To understand what's going on, one has to know how Node works and what keeps a Node process alive. Node's event loop keeps running until all timers and I/O operations are done.
Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.
If there's nothing to do anymore, a Node process exits.
Surprisingly, pending promises don't keep a Node process alive. If you want to read more about this behavior, this lengthy GitHub discussion might help.
There are pros and cons to not waiting for promise resolution, but there are situations when you want to exit a process despite pending promises.
Look at the following code:
let p1 = Promise.resolve(1);
let p2 = new Promise(() => {}).then(Promise.resolve(2));
// take the first settled promise and log its value
let p3 = Promise.race([p1, p2]).then(console.log);
Even though this is just a simple example, in a real codebase, I could see the need to exit a Node script, even though there are pending promises.
But here's the catch! This Node.js behavior can be surprising, but it also depends on how you run Node!
Pending promises in CommonJS
Let's look at the code in CommonJS world. Top-level await isn't supported there, so an asynchronous IFEE (immediately invoked function expression) needs to be added.
// index.js (CJS)
(async () => {
const wait = (time) =>
new Promise((resolve) => {
setTimeout(() => resolve, time);
});
console.log("waiting");
await wait(1000);
console.log("done");
})();
// Exit code: 0
And there we have it. This script exits without considering pending promises.
But where's the exit code 13
?
Pending top-level promises in ECMAScript modules
When top-level await landed in Node ECMAScript modules, Anna Henningsen improved the main promise handling.
From what I understand, if you're executing an ES module, your entry code is somehow wrapped in a promise (or async function?) — the main promise.
Now, if Node exits (remember that pending promises don't keep Node alive) before this main promise is settled, it fails and uses the exit code 13
.
// index.mjs (ESM)
await new Promise(() => {});
// -----
// Exit code: 13
Similarly, when your main promise rejects, the status code 1
is used.
// index.mjs (ESM)
await Promise.reject(new Error("Oh no!!!"));
// -----
// Exit code: 1
Node keeps track of whatever happens to your main promise! 💯
But watch out! The described behavior doesn't mean that all deeply buried and unresolved promises will now lead to a returned 13
status code.
It only affects your top-level promise. That's why the script below exits with status code 0
. The main promise fulfills fine, even though there's a pending promise.
// index.mjs (ESM)
async function run() {
await new Promise(() => {})
}
run();
// Exit code: 0
Here's the main promise handling if you're curious and want to read some Node.js core code.
So, what did I learn?
After my investigation, it makes sense that pending promises don't keep the event loop running. I can see how it can be surprising when writing scripts, though.
Second, ESM modules come with a little bit of DX sugar to provide information on "unfinished business". I'd welcome a message paired with the status code, but hey... 🤷♂️ Better than nothing.
And lastly, even though the Node.js code base is massive, you should check it out! It's reasonably good to read.
And this is it for today, happy scripting!
Join 5.5k readers and learn something new every week with Web Weekly.