Global Promise Rejection Handling
One of the most controversial aspects of promises is the silent failure that occurs when a promise is rejected without a rejection handler. Some consider this the biggest flaw in the specification as it’s the only part of the JavaScript language that doesn’t make errors apparent.
Determining whether a promise rejection was handled isn’t straightforward due to the nature of promises. For instance, consider this example:
let rejected = Promise.reject(42);
// at this point, rejected is unhandled
// some time later...
rejected.catch(function(value) {
// now rejected has been handled
console.log(value);
});
You can call then()
or catch()
at any point and have them work correctly regardless of whether the promise is settled or not, making it hard to know precisely when a promise is going to be handled. In this case, the promise is rejected immediately but isn’t handled until later.
While it’s possible that the next version of ECMAScript will address this problem, both browsers and Node.js have implemented changes to address this developer pain point. They aren’t part of the ECMAScript 6 specification but are valuable tools when using promises.
Node.js Rejection Handling
In Node.js, there are two events on the process
object related to promise rejection handling:
unhandledRejection
: Emitted when a promise is rejected and no rejection handler is called within one turn of the event looprejectionHandled
: Emitted when a promise is rejected and a rejection handler is called after one turn of the event loop
These events are designed to work together to help identify promises that are rejected and not handled.
The unhandledRejection
event handler is passed the rejection reason (frequently an error object) and the promise that was rejected as arguments. The following code shows unhandledRejection
in action:
let rejected;
process.on("unhandledRejection", function(reason, promise) {
console.log(reason.message); // "Explosion!"
console.log(rejected === promise); // true
});
rejected = Promise.reject(new Error("Explosion!"));
This example creates a rejected promise with an error object and listens for the unhandledRejection
event. The event handler receives the error object as the first argument and the promise as the second.
The rejectionHandled
event handler has only one argument, which is the promise that was rejected. For example:
let rejected;
process.on("rejectionHandled", function(promise) {
console.log(rejected === promise); // true
});
rejected = Promise.reject(new Error("Explosion!"));
// wait to add the rejection handler
setTimeout(function() {
rejected.catch(function(value) {
console.log(value.message); // "Explosion!"
});
}, 1000);
Here, the rejectionHandled
event is emitted when the rejection handler is finally called. If the rejection handler were attached directly to rejected
after rejected
is created, then the event wouldn’t be emitted. The rejection handler would instead be called during the same turn of the event loop where rejected
was created, which isn’t useful.
To properly track potentially unhandled rejections, use the rejectionHandled
and unhandledRejection
events to keep a list of potentially unhandled rejections. Then wait some period of time to inspect the list. For example:
let possiblyUnhandledRejections = new Map();
// when a rejection is unhandled, add it to the map
process.on("unhandledRejection", function(reason, promise) {
possiblyUnhandledRejections.set(promise, reason);
});
process.on("rejectionHandled", function(promise) {
possiblyUnhandledRejections.delete(promise);
});
setInterval(function() {
possiblyUnhandledRejections.forEach(function(reason, promise) {
console.log(reason.message ? reason.message : reason);
// do something to handle these rejections
handleRejection(promise, reason);
});
possiblyUnhandledRejections.clear();
}, 60000);
This is a simple unhandled rejection tracker. It uses a map to store promises and their rejection reasons. Each promise is a key, and the promise’s reason is the associated value. Each time unhandledRejection
is emitted, the promise and its rejection reason are added to the map. Each time rejectionHandled
is emitted, the handled promise is removed from the map. As a result, possiblyUnhandledRejections
grows and shrinks as events are called. The setInterval()
call periodically checks the list of possible unhandled rejections and outputs the information to the console (in reality, you’ll probably want to do something else to log or otherwise handle the rejection). A map is used in this example instead of a weak map because you need to inspect the map periodically to see which promises are present, and that’s not possible with a weak map.
While this example is specific to Node.js, browsers have implemented a similar mechanism for notifying developers about unhandled rejections.
Browser Rejection Handling
Browsers also emit two events to help identify unhandled rejections. These events are emitted by the window
object and are effectively the same as their Node.js equivalents:
unhandledrejection
: Emitted when a promise is rejected and no rejection handler is called within one turn of the event loop.rejectionhandled
: Emitted when a promise is rejected and a rejection handler is called after one turn of the event loop.
While the Node.js implementation passes individual parameters to the event handler, the event handler for these browser events receives an event object with the following properties:
type
: The name of the event ("unhandledrejection"
or"rejectionhandled"
).promise
: The promise object that was rejected.reason
: The rejection value from the promise.
The other difference in the browser implementation is that the rejection value (reason
) is available for both events. For example:
let rejected;
window.onunhandledrejection = function(event) {
console.log(event.type); // "unhandledrejection"
console.log(event.reason.message); // "Explosion!"
console.log(rejected === event.promise); // true
};
window.onrejectionhandled = function(event) {
console.log(event.type); // "rejectionhandled"
console.log(event.reason.message); // "Explosion!"
console.log(rejected === event.promise); // true
};
rejected = Promise.reject(new Error("Explosion!"));
This code assigns both event handlers using the DOM Level 0 notation of onunhandledrejection
and onrejectionhandled
. (You can also use addEventListener("unhandledrejection")
and addEventListener("rejectionhandled")
if you prefer.) Each event handler receives an event object containing information about the rejected promise. The type
, promise
, and reason
properties are all available in both event handlers.
The code to keep track of unhandled rejections in the browser is very similar to the code for Node.js, too:
let possiblyUnhandledRejections = new Map();
// when a rejection is unhandled, add it to the map
window.onunhandledrejection = function(event) {
possiblyUnhandledRejections.set(event.promise, event.reason);
};
window.onrejectionhandled = function(event) {
possiblyUnhandledRejections.delete(event.promise);
};
setInterval(function() {
possiblyUnhandledRejections.forEach(function(reason, promise) {
console.log(reason.message ? reason.message : reason);
// do something to handle these rejections
handleRejection(promise, reason);
});
possiblyUnhandledRejections.clear();
}, 60000);
This implementation is almost exactly the same as the Node.js implementation. It uses the same approach of storing promises and their rejection values in a map and then inspecting them later. The only real difference is where the information is retrieved from in the event handlers.
Handling promise rejections can be tricky, but you’ve just begun to see how powerful promises can really be. It’s time to take the next step and chain several promises together.