Skip to content

Why do I get an Unhandled Promise Rejection with await Promise.all

This is the general structure of my code:

(async () => {
  try {
    const asyncActions = []

    for (let i = 0; i < 3; i++) {
      await new Promise((resolve, reject) => setTimeout(resolve, 1000))

      for (let j = 0; j < 3; j++) {
        asyncActions.push(new Promise((resolve, reject) => setTimeout(reject, 1000)))
      }
    }

    await Promise.all(asyncActions)
    console.log('all resolved')
  }
  catch (e) {
    console.log('caught error', e)
  }
})()

I expect this to catch any rejections happening in asyncActions because they should be handled by Promise.all(), but somehow they are unhandled? The console shows the following:

(node:9460) UnhandledPromiseRejectionWarning: undefined
(Use `node --trace-warnings ...` to show where the warning was created)
(node:9460) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:9460) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
(node:9460) UnhandledPromiseRejectionWarning: undefined
(node:9460) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
...

(node:9460) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 1)
(node:9460) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 2)
...

Why are they not handled by Promise.all() and then caught in the catch block?

I also noticed that when I replace both the new Promise(...) with just Promise.resolve() and Promise.reject() respectively it catches the errors. Why is that? Aren’t both variants asynchronous and thus should work the same way?

Answer

The way we detect non-handling of promise rejections in Node.js is using a heuristic.

When a promise is rejected we give the user a chance to still attach a listener (synchronously) – if they don’t we assume it’s not handled and cause an unhandledRejection. This is because:

  • It’s impossible (as in the halting problem) to know if a user will ever attach such a handler in the future.
  • In the vast majority of cases it’s sufficient, since it’s best-practice to always immediately attach listeners.

So – you need to always add the catch listeners synchronously in order to avoid the unhandled rejections.

You can also (in your non-contrived case) just opt out by adding an empty catch listener in a fork:

(async () => {
  try {
    const asyncActions = []

    for (let i = 0; i < 3; i++) {
      await new Promise((resolve, reject) => setTimeout(resolve, 1000))

      for (let j = 0; j < 3; j++) {
        const p = new Promise((resolve, reject) => setTimeout(reject, 1000));
        p.catch(() => {}); // suppress unhandled rejection
        asyncActions.push(p)
      }
    }

    await Promise.all(asyncActions)
    console.log('all fulfilled')
  }
  catch (e) {
    console.log('caught error', e)
  }
})()

  • Fun historical tidbit #1: we considered using GC to detect unhandledRejections – people found it more confusing.
  • Fun historical tidbit #2: Chrome does the same thing but actually removes the messages retrospectively if you add a listener.
  • Fun historical tidbit #3: This heuristic originated in bluebird in 2013. I talk about it here.