catchjs

Error handling with async/await and promises, n² ways to shoot yourself in the foot

Browsers now have native support for doing asynchronous calls via async/await. This is nice. It is essentially syntax support for promises.

Unfortunately, the error handling story for all this not so nice. The mechanisms are interwoven, and don't always interact with each other in a clean way. The following table summarizes this, and we'll dig into it further in the text below.

Overview of async exception handling

If I cause an error with 🢂
can I catch it with 🢃?
throw new Error() reject()
try {} catch {} Yes, but if the throw happens in a Promise it must have been awaited with the await syntax, and resolve must not have been called before the throw. Will not catch errors thrown in another call stack via a setTimeout() or setInterval() callback. Yes, but only if the function was called with the await syntax, and only if resolve() has not been called for the promise already.
promise.catch(e => {}) Yes, unless resolve() was called earlier or the error happened in an asynchronous callback function, for example, a function passed to setTimeout(). Yes, unless resolve() was called earlier.
window.onunhandledrejection Yes, but not until script execution has completed, your call stack is unwound, and control is yielded back to the runtime, and none of the other mechanisms have dealt with error up until then.
window.onerror Not if the error was thrown in a Promise. No.
Real footage of the async error delegation mechanism

How did we end up with this? Well, when adding new features to a system, if every feature number n has to interact with all of the existing n-1 features, you get an O(n²) growth in feature-feature interactions. So for a linear growth in features, you get a quadratic growth in complexity. This actually explains why most big software projects fail, and why disentangling features is so important. It's also what has happened to async error handling. We started with simple callback functions, and found that it was a mess. Then we fixed that mess with Promises, and found that that solution also was a bit of a mess. Then we fixed that mess with async/await.

So let's dig into the current mess.

Handling errors in promises locally

Promises, they break before they're made
Sometimes, sometimes
- The Strokes, in a post to the WHATWG mailing list

Thrown errors

When an error is thrown in an async function, you can catch it with a try {} catch {}. So this works as you'd expect:

async function fails() {
    throw Error();
}

async function myFunc() {
    try {
        await fails();
    } catch (e) {
        console.log("that failed", e); 
    }
}

This is syntax sugar for what you might have been doing with promises earlier:

fails().catch(e => {
    console.log("That also failed", e); 
});

In fact, anywhere you use the keyword await, you can remove await and do the traditional .then() and .catch() calls. This is because the async keyword implicitly creates a Promise for its function.

The only difference between these two is that the callback for catch() has it's own execution context, i.e. variable scope works like you'd expect it to.

Rejected promises

So with Promises, it turns out you have another way of throwing errors, other than using throw, namely by calling reject():

function fails2() {
    return new Promise((resolve, reject) => {
        reject(new Error());
    });
}

async function myFunc2() {
    try {
        await fails2();
    } catch (e) {
        console.log("that failed", e); 
    }
}

Errors passed to reject() can be caught with both try {} catch {} and with the .catch() method. So you have two ways to throw errors, and two ways to catch errors. This is more complex than we'd like, but at least each way of catching errors will catch both ways of throwing them, so the complexity here isn't fully as bad as it could have been.

Errors thrown in a different call stack

There's more troubly to be had though. If you're creating Promise yourself, chances are you're using either a setTimeout() or a setInterval(), or in some way calling a callback function when some operation is done. These callbacks will be called from a different call stack, which means that thrown errors will propagate to somewhere that is not your code.

Consider this example:

function fails3() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            throw new Error();
        }, 100);
    });
}

async function myFunc3() {
    try {
        await fails3();
    } catch (e) {
        console.log("that failed", e); //<-- never gets called
    }
}

The error produced here is never caught by the try {} catch {}, because it is thrown on a different call stack. Using the .catch(() => {}) method would have the same problem.

The way to have an error propagate across such callbacks is to use the reject() function, like so:

function fails4() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            reject(new Error());
        }, 100);
    });
}

async function myFunc4() {
    try {
        await fails4();
    } catch (e) {
        console.log("that failed", e); //<-- this gets called
    }
}

This is presumably the main reason why the reject/resolve paradigm was introduced in the first place.

Sidenote: Why reject/resolve kind of sucks.

Calling reject(new Error()) in a promise is much like doing throw Error(), except for a major difference: It's just a function call, so it doesn't break the execution flow like throw does. This means you can write paradoxical code that both rejects and resolves, like this:

function schrödinger() {
    return new Promise((resolve, reject) => {
        reject(new Error());
        resolve("great success");
    });
}

Here both reject() and resolve() will be called. So which will win? The answer is whichever function was called first.

Now look at this weirdo:

function schrödinger2() {
    return new Promise((resolve, reject) => {
        throw resolve("huh"); //<-- this throw is executed
    });
}
async function callAsync() {
    try {
        await schrödinger2();
    } catch (e) {
        console.log("caught error", e); //<-- yet, this is never reached
    }
}

Here the promise has a single line of code, a throw statement. Yet, the try {} catch {} is never triggered. This is because resolve was called, and the rule still is that whatever was called first is what wins. So the throw is executed, but it is silently swallowed by the runtime. This is bound to cause endless confusion.

These problems happen because resolve() and reject() are near duplicates of return and throw. I'll claim that the only reason we have reject/resolve is to be able to move errors across call stack boundaries. But it's a mediocre fix for that, for several reasons. It only moves the errors you expect, so e.g. an unexpected NullReferenceException will not be moved across boundaries unless you explicitly call reject() with it yourself. Also, the fact that it duplicates core language features causes a lot of problems, as seen above.

There's a cleaner design for this. C# has had async/await since before people started talking about it in JavaScript. There, exceptions thrown in the async callbacks are caught, and then rethrown such that they propagate to the site that is awaiting the async operation. JavaScript could implement this by providing substitutes for setTimeout and setInterval with new semantics for errors, and we could ditch this resolve/reject stuff in favor of return/throw. This would also cut down the Promises spec by 90%.

Handling errors in promises globally

So we know how to catch errors with try {} catch {} and similar mechanisms. What about when you want to set up a global catch-all handler for all unhandled errors, for example to log these errors to a server?

Well, how do you even tell if an error in a promise is unhandled? When dealing with promises, you have no way of knowing if an error will be handled some time in the future. The promise might call reject(), and some code might come along 10 minutes later and call .catch(() => {}) on that promise, in which case the error will be handled. For this reason, the global error handler in Promise libraries like Q and Bluebird has been named onPossiblyUnhandledRejection, which is a fitting name. In native Promises, this function is called onunhandledrejection, but they still can only tell if a rejection has been unhandled so far. So onunhandledrejection is only triggered when the currently running script has completed and control has been yielded back to the runtime, if nothing else has caught the error in the meantime.

You can set up your global handler for async exceptions and rejections like this:

window.onunhandledrejection = function(evt) { /*Your code*/ }

or:

window.addEventListener("unhandledrejection", function(evt) { /*Your code*/ })

Here evt is an object of type PromiseRejectionEvent. evt.promise is the promise that was rejected, and evt.reason holds whatever object was passed to the reject() function.

This is all nice and dandy, except for this: No one except Chrome implement it (well, Chrome, and Chromium based browsers). It is coming to Firefox, and presumably to Safari and Edge as well. But not yet. To make matters worse, there is no good work around for these browsers, other than not using native Promises, and relying on a library like Q or Bluebird instead. Hopefully native support will arrive for these browsers soon. Summer 2019 update: unhandledrejection is now supported by Chrome, FireFox, Edge and Safari.

Logging errors in promises with CatchJS

CatchJS instruments the browser with a global error handler, in order to track uncaught errors that occur. Deployment is simply done by dropping in a script file.

<script src="https://cdn.catchjs.com/catch.js"></script>

With this, uncaught errors get logged, along with various telemetry, which can include screenshots and click trails.

CatchJS does not attach it self to the onunhandledrejection handler. If you want this, you can set up such forwarding manually.

window.onunhandledrejection = function(evt) {
    console.error(evt.reason);
}

CatchJS will instrument console.error, so these errors will be logged to your remote persistent log, as well as to the developers console.


If you liked this post, check out our post on error handling and error boundaries in Vue, or our post on React error handling and error boundaries.