Published at
Updated at
Reading time
4min
This post is part of my Today I learned series in which I share all my web development learnings.

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?

Pending promises don't keep the Node event loop alive

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.

Conclusion

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!

Was this TIL post helpful?
Yes? Cool! You might want to check out Web Weekly for more quick learnings. The last edition went out 18 days ago.
Stefan standing in the park in front of a green background

About Stefan Judis

Frontend nerd with over ten years of experience, freelance dev, "Today I Learned" blogger, conference speaker, and Open Source maintainer.

Related Topics

Related Articles