Search code examples
javascriptnode.jsasynchronouspromise

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?


Solution

  • 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.