Skip to content
Advertisement

Unexpected unhandledRejection event for promise which rejection does get handled

Updated, I’ve now tried explaining the behavior I’m seeing, but it’d still be great to have an answer from a credible source about the unhandledRejection behavor. I’ve also started a discussion thread on Reddit.


Why do I get an unhandledRejection event (for “error f1”) in the following code? That’s unexpected, because I handle both rejections in the finally section of main.

I’m seeing the same behavior in Node (v14.13.1) and Chrome (v86.0.4240.75):

window.addEventListener("unhandledrejection", event => {
  console.warn(`unhandledRejection: ${event.reason.message}`);
});

function delay(ms) {
  return new Promise(r => setTimeout(r, ms));
}

async function f1() {
  await delay(100);
  throw new Error("error f1");
}

async function f2() {
  await delay(200);
  throw new Error("error f2");
}

async function main() {
  // start all at once
  const [p1, p2] = [f1(), f2()];
  try {
    await p2;
    // do something after p2 is settled
    await p1;
    // do something after p1 is settled
  }
  finally {
    await p1.catch(e => console.warn(`caught on p1: ${e.message}`));
    await p2.catch(e => console.warn(`caught on p2: ${e.message}`));
  }
}

main().catch(e => console.warn(`caught on main: ${e.message}`));

Advertisement

Answer

Ok, answering to myself. I misunderstood how unhandledrejection event actually works.

I’m coming from .NET where a failed Task object can remain unobserved until it gets garbage-collected. Only then UnobservedTaskException will be fired, if the task is still unobserved.

Things are different for JavaScript promises. A rejected Promise that does not have a rejection handler already attached (via then, catch, await or Promise.all/race/allSettle/any), needs one as early as possible, otherwise unhandledrejection event may be fired.

When unhandledrejection will be fired exactly, if ever? This seems to be really implementation-specific. The W3C specs on “Unhandled promise rejections” do not strictly specify when the user agent is to notify about rejected promises.

To stay safe, I’d attach the handler synchronously, before the current function relinquishes the execution control to the caller (by something like return, throw, await, yield).

For example, the following doesn’t fire unhandledrejection, because the await continuation handler is attached to p1 synchronously, right after the p1 promise gets created in already rejected state. That makes sense:

window.addEventListener("unhandledrejection", event => {
  console.warn(`unhandledRejection: ${event.reason.message}`);
});

async function main() {
  const p1 = Promise.reject(new Error("Rejected!")); 
  await p1;
}

main().catch(e => console.warn(`caught on main: ${e.message}`));

The following still does not fire unhandledrejection, even though we attach the await handler to p1 asynchronously. I could only speculate, this might be happening because the continuation for the resolved promised is posted as a microtask:

window.addEventListener("unhandledrejection", event => {
  console.warn(`unhandledRejection: ${event.reason.message}`);
});

async function main() {
  const p1 = Promise.reject(new Error("Rejected!")); 
  await Promise.resolve(r => queueMicrotask(r));
  // or we could just do: await Promise.resolve();
  await p1;
}

main().catch(e => console.warn(`caught on main: ${e.message}`));

Node.js (v14.14.0 at the time of posting this) is consistent with the browser behavior.

Now, the following does fire the unhandledrejection event. Again, I could speculate that’s because the await continuation handler is now attached to p1 asynchronously and on some later iterations of the event loop, when the task (macrotask) queue is processed:

window.addEventListener("unhandledrejection", event => {
  console.warn(`unhandledRejection: ${event.reason.message}`);
});

async function main() {
  const p1 = Promise.reject(new Error("Rejected!")); 
  await new Promise(r => setTimeout(r, 0));
  await p1;
}

main().catch(e => console.warn(`caught on main: ${e.message}`));

I personally find this whole behavior confusing. I like the .NET approach to observing Task results better. I can think of many cases when I’d really want to keep a reference to a promise and then await it and catch any errors on a later timeline to that of its resolution or rejection.

That said, there is an easy way to get the desired behavior for this example without causing unhandledrejection event:

window.addEventListener("unhandledrejection", event => {
  console.warn(`unhandledRejection: ${event.reason.message}`);
});

async function main() {
  const p1 = Promise.reject(new Error("Rejected!"));
  p1.catch(console.debug); // observe but ignore the error here
  try {
    await new Promise(r => setTimeout(r, 0));
  }
  finally {
    await p1; // throw the error here
  }
}

main().catch(e => console.warn(`caught on main: ${e.message}`));
User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement