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

Browsers now have native support for doing asyncronous 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. Here's what we've got to deal with:

Ugh.

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, where we started with simple callback functions, then we fixed that mess with Promises, 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() {
    throws 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.

You can set up your global handler 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.

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="//cdn.catchjs.com/catch.js"></script>

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

Because of the ambiguity of whether or not a Promise rejection will be handled in the future, 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.