Handling Rejection - Tales From The Event Loop Ep. 2
David N. Booth
Everyone has to deal with rejection sometimes, even our lovingly-crafted lines of JavaScript code! Join me as we scuba dive into the event loop and find out how to handle rejection (and what happens if we don't).
This article assumes the reader has a thorough understanding of the JavaScript event loop, task queue, microtask queue, and Promise creation. If you haven't read Episode 1, it is highly recommended! (by me, the totally unbiased author).
Scuba Equipment - Tools for Diving In
Here's the knowledge we need to take with us:
- If a Promise rejects without any rejection handlers registered, nothing happens. There are, after all, no rejection handlers registered. The host environment totally knows though. It's watching you.
- At the end of a turn of the event loop, if a Promise rejected during that turn without ever having a rejection handler registered, the host environment makes a big fuss.
- If you register a rejection handler for a Promise which rejected on a previous turn of the event loop (and was never handled) it is considered to be "handled late". In response, the host environment emits a 'rejectionHandled' event.
await is a rejection handler. An await-ed Promise will never have an unhandled rejection.
The second point isn't very precise. What does it mean to "make a big fuss"? This is up to the environment, and server runtimes deal with it differently than browsers. (yaaaaay)
Two roads diverged in a wood, and I—
I took the one traveled by NodeJS
- Robert Frost, early draft of The Road Not Taken (maybe)
Web Browser: Emits an unhandledRejection event onto the task queue.
NodeJS: The behaviour can be defined with the --unhandled-rejections flag. The default mode is 'throw', which will look for a registered unhandledRejection handler on the process. If there is one, it runs it. If there isn't one, it raises an error.
Notice a couple of key differences here. In the web browser, the unhandledRejection event goes onto the task queue and previously-queued tasks will run first. In Node, the handler is run immediately. In the browser, the event loop continues happily executing. In Node, its a fatal error which will end the program execution unless you catch it and handle it.
Code Examples
Diving Practice - In the Swimming Pool
Q: What does this code output?
(async () => {
Promise.reject("Rejected!");
})();
The Promise rejects without any handlers, so we get an error message about the unhandled rejection. The host environment gets to decide what the error message contents will be. Here are the variations I came across:
A - Edge/Chrome/Firefox:
Uncaught (in promise) Rejected!
A - Safari:
Unhandled Promise Rejection: Rejected! expandable to view a stack trace
A - Deno REPL:
Uncaught undefined
A - Deno Script:
error: Uncaught (in promise) "Rejected!"
A - Bun Script/REPL:
error: Rejected!
Rejected!
A - Node REPL:
Uncaught 'Rejected!'
A - Node Script:
node:internal/process/promises:392
new UnhandledPromiseRejection(reason);
^
UnhandledPromiseRejection: 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(). The promise rejected with the reason "Rejected!".
at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
at processPromiseRejections (node:internal/process/promises:475:17)
at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
code: 'ERR_UNHANDLED_REJECTION'
}
For the rest of this article, when describing error outputs, I'll stick to one output and only mention others if the difference is notable in some way.
The next two examples might look a little strange, but bear with me here. We could try to catch the error with a try-catch, or by putting a .catch on the wrapping function.
Q: What do these examples output?
(async () => {
try {
Promise.reject("Rejected!");
} catch {
console.log("Caught!");
}
})();
(async () => {
Promise.reject("Rejected!");
})().catch(() => { console.log("Caught!") });
A - Edge/Chrome/Firefox:
Uncaught (in promise) Rejected!
A - Safari:
Unhandled Promise Rejection: Rejected! and then a stack trace
A - Node REPL:
Uncaught 'Rejected!'
A - Node Script:
node:internal/process/promises:392
new UnhandledPromiseRejection(reason);
^
UnhandledPromiseRejection: 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(). The promise rejected with the reason "Rejected!".
at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
at processPromiseRejections (node:internal/process/promises:475:17)
at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
code: 'ERR_UNHANDLED_REJECTION'}
A - Deno REPL:
Uncaught undefined
A - Deno Script:
error: Uncaught (in promise) "Rejected!"
A - Bun Script/REPL:
error: Rejected!
Rejected!
The inner Promise that we rejected is kind of just hanging out in space after being created. It doesn't really have any connection to the function that created it, and neither of the above efforts to catch it really affect it at all. It still rejects, and there are still no handlers on it. Let's try something different.
Q: What does this code output?
(async () => {
await Promise.reject("Rejected!");
})();
A - Edge/Chrome/Firefox:
Uncaught (in promise) Rejected!
A - Safari:
Unhandled Promise Rejection: Rejected! and then a stack trace
A - Node REPL:
Uncaught 'Rejected!'
A - Node Script:
node:internal/process/promises:392
new UnhandledPromiseRejection(reason);
^
UnhandledPromiseRejection: 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(). The promise rejected with the reason "Rejected!".
at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
at processPromiseRejections (node:internal/process/promises:475:17)
at process.processTicksAndRejections (node:internal/process/task_queues:106:32) {
code: 'ERR_UNHANDLED_REJECTION'
}
A - Deno REPL:
Uncaught undefined
A - Deno Script:
error: Uncaught (in promise) "Rejected!"
A - Bun Script/REPL:
error: Rejected!
Rejected!
We still get an error message because of an unhandled Promise rejection.
Q: In the above example, what Promise did the host environment see reject without any handlers?
A:
The Promise returned by the outer async function, (async () => {})();
The takeaway here is that when you await a Promise, you are registering a rejection handler for that Promise. If the Promise rejects, this handler will throw an error in the code which await-ed the Promise. This will often, like in our example here, lead to an unhandled Promise rejection, but the Promise which the host environment sees rejecting without handlers won't be the Promise which was await-ed.
If we try the same two things as before: catching the error with a try-catch, or putting a .catch on the wrapping function, we can see this in action:
Q: What do these examples output?
(async () => {
try {
await Promise.reject("Rejected!");
} catch {
console.log("Caught!");
}
})();
(async () => {
await Promise.reject("Rejected!");
})().catch(() => { console.log("Caught!") });
A:
Caught!
Diving Deeper
Usually, when we write lovingly-crafted lines of JavaScript code with Promises, we follow a happy path where, within the same synchronous block, we define a Promise and its handlers, and then let things run their course. This is a good thing, because if you always define the handlers for a Promise synchronously with the Promise creation, then you don't need to read the rest of this article. With all the time you save, you can do something actually interesting, like watching glue dry.
However, because JavaScript lets us save references to Promises in variables, and because the lifetime of a JavaScript variable can be a lot longer than one tick of the event loop, we can actually add rejection handlers to a Promise at any time - even long after it first rejected.
Let's descend down in this direction and see what we find.
Level 1 - Register the handler during the event loop turn
Q: What do we get if we register a handler asynchronously during a microtask even later than when the Promise first rejects?
(async () => {
const p = Promise.reject("Rejected!");
queueMicrotask(() => {
p.catch(() => { console.log("Caught!") });
});
})();
A:
Caught!
This is fine. It isn't that different from synchronously calling .catch, because we still registered the handler during the event loop turn.
Level 2 - Register the handler during a task before the unhandledRejection event
This one is only for the web browsers. If we let the Promise rejection go unhandled until the end of the event loop, the browser will schedule an unhandledRejection event on the task queue.
Q: What happens if we already scheduled an earlier task which will catch the Promise rejection?
window.onunhandledrejection = () => { console.log("unhandledRejection") };
window.onrejectionhandled = () => { console.log("rejectionHandled") };
(async () => {
const p = Promise.reject("Rejected!");
setTimeout(() => {
p.catch(() => { console.log("Caught!") });
},
0
);
})();
A - Edge/Chrome/Safari:
Caught!
A - Firefox:
Caught!
Uncaught (in promise) Rejected!
My interpretation of the difference here is that Firefox makes the decision to log the error message upon seeing the unhandled rejection at the end of the event loop, while the other browsers decide to do it in response to the unhandledRejection event bubbling up. This difference could never matter to your program's execution, but may be confusing to a developer seeing the inconsistent behaviour.
In any environment, we never see the log message we added to the global unhandled rejection handler, so it looks like that task is never run. Let's explore why.
Once synchronous execution is done, the queues look like this:
Task Queue:
Microtask Queue:
Then, the host environment sees the unhandled rejection, and adds that event:
Task Queue:
unhandledRejection
• Promise.reject()
Microtask Queue:
Then the event loop continues, meaning that the next runnable task comes off the queue. This is our setTimeout, where we register a rejection handler. Then the next runnable task comes off the queue. We never see the unhandled rejection, so I believe the host environment no longer considers that task to be "runnable", since a rejection handler was added.
The takeaway here is that because the browser handles unhandledRejections with an event on the task queue, you actually have time to sneak in before that task and handle the rejection.
Level 4 - Register the handler during a task after the unhandledRejection event
Oops! We swam right past Level 3. Don't worry, we'll come back to it on the way up.
We can repeat the above example with a non-zero setTimeout. This means that the timeout task won't be immediately runnable and the unhandledRejection will come off first, and then we'll catch it in a later event loop turn.
Q: What will we have when this code is finished running?
window.onunhandledrejection = () => { console.log("unhandledRejection") };
window.onrejectionhandled = () => { console.log("rejectionHandled") };
(async () => {
const p = Promise.reject("Rejected!");
setTimeout(() => {
p.catch(() => { console.log("Caught!") });
},
5000
);
})();
A - Edge/Chrome/Node/Bun/Deno:
unhandledRejection
Caught!
rejectionHandled
A - Firefox:
unhandledRejection
Uncaught (in promise) Rejected!
Caught!
rejectionHandled
A - Safari:
unhandledRejection
Unhandled Promise Rejection: Rejected!
Caught!
rejectionHandled
We finally get to see an unhandled rejection. p rejected, wasn't handled, the event loop ended, and the unhandledRejection was processed. In a later event loop turn, we added a rejection handler, so the host environment saw this and emitted a rejectionHandled event.
As an interesting exercise, you can try this code as well, in any environment:
window.onunhandledrejection = () => { console.log("unhandledRejection") };
window.onrejectionhandled = () => { console.log("rejectionHandled") };
(async () => {
const p = Promise.reject("Rejected!");
setTimeout(async () => {
await p;
},
5000
);
})();
Even though all we did in the setTimeout was await p (which will raise an error and the setTimeout callback will return a rejected Promise), we still get a rejectionHandled event. await really counts as registering a rejection handler!
Level 3 - Register the handler during the unhandledRejection event
This is pretty silly, but just for completeness, let's try it. We saw what happens if we handle a rejection before the rejection event and after the rejection event, but what if we handle it during the rejection event?
Q: What will we have when this code is finished running? Make literally any guess you want, because this will never matter to your life.
let p;
window.onunhandledrejection = () => {
console.log("unhandledRejection");
p.catch(() => { console.log("Caught!") });
};
window.onrejectionhandled = () => { console.log("rejectionHandled") };
(async () => {
p = Promise.reject("Rejected!");
})();
A - Edge/Chrome/Firefox:
unhandledRejection
Caught!
Uncaught (in promise) Rejected!
A - Safari:
unhandledRejection
Caught!
Unhandled Promise Rejection: Rejected!
A - Node/Bun/Deno:
unhandledRejection
Caught!
rejectionHandled
In all cases we get the unhandledRejection event and the "Caught!" message, because we waited until the unhandledRejection event and then caught it.
As for the "rejectionHandled" event, we did get to the rejection and add a handler to it before the next turn of the event loop, although it was after all the normal code execution for that turn was complete. It appears that the browsers don't consider this to be "handled late", but the server environments do.
In the browsers, we still get the error message even though we handled the rejection, because the browser ran the unhandledRejection event. This is the expected behaviour according to MDN - the error message is printed if the event bubbles. We can try preventing this.
Q: What happens if we call preventDefault() on the unhandledRejection event?
let p;
window.onunhandledrejection = (e) => {
console.log("unhandledRejection");
p.catch(() => { console.log("Caught!") });
e.preventDefault();
};
window.onrejectionhandled = () => { console.log("rejectionHandled") };
(async () => {
p = Promise.reject("Rejected!");
})();
A - Edge/Chrome/Safari:
unhandledRejection
Caught!
A - Firefox:
unhandledRejection
Caught!
Uncaught (in promise) Rejected!
Looks like Firefox might be going a bit off-spec here, and this is in line with the example in Level 2 where I speculated that Firefox isn't using this event to trigger the error message. Otherwise, calling preventDefault() in the unhandledRejection handler stops the error message from being logged. Cool.
Takeaways
If you made it this far, thanks for sticking with me! We did some pretty silly stuff here, and I think the most important bit was the Scuba Equipment that we started off with. Even though its at the start of the article, it really is the final result of the research and experimentation process described here. The code examples, although interesting, feel like they are mostly examples of things you shouldn't do. Register your Promise handlers synchronously when you create the Promise and you'll thank yourself for it!
Footnotes