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}`));