DEV Community

loading...
Cover image for Error handling with async/await and promises

Error handling with async/await and promises

vcarl profile image Carl Vitullo Originally published at Medium Updated on ・5 min read

(Photo by Hunter Newton on Unsplash)

I love promises. They're a fantastic model for asynchronous behavior, and await makes it very easy to avoid callback hell (though I'd argue promises do a great job of that on their own). Once you can build a mental model for how promises work, you can build some very complex asynchronous flows in a handful of lines of code.

As much as I love having async/await in my toolbox, there are several quirks to handling errors when using it. It's very easy to write error handling in a way that it swallows more errors than you want, and strategies to work around that negate some of the readability advantages that async/await brings.

With async/await, a common way to handle errors when awaiting a promise is to wrap it with a try/catch block. This leads to a relatively straightforward failure case: if you do anything else inside your try block, any exceptions thrown will be caught.

Regular async/await

async () => {
  try {
    const data = await fetchData();
    doSomethingComplex(data);
  } catch (e) {
    // Any errors thrown by `fetchData` or `doSomethingComplex` are caught.
  }
}

This is an unfortunate interaction between async/await and JS exceptions. If JS had a mechanism to catch only certain exceptions, we would be able to describe the errors we want to handle with more precision. Of course, then we’d be writing Java.

The most obvious solution to this is moving your heavy lifting outside of the try block, but this isn't very satisfying. The flow of data becomes odd, and you can't use const even though there's only 1 assignment.

Logic extracted from try blocks

async () => {
  let data;
  try {
    data = await fetchData();
  } catch (e) {
    // Only errors from `fetchData` are caught.
    return;
  }
  doSomethingComplex(data);
};

This code is not particularly pleasant to read and only gets more unpleasant as you handle more potential edge cases. It also requires discipline to keep up and has a high potential for accidentally swallowing errors in the future. Code that requires discipline to maintain correctly is problematic; human error becomes unavoidable beyond a certain scale.

Awaiting a promise doesn't make it go away, however. Because there is still a promise, you can handle errors as you would without awaiting it.

Await with .catch()

async () => {
  const data = await fetchData().catch(e => {
    // Only errors from `fetchData` are caught.
  });
  if (!data) return;
  doSomethingComplex(data);
};

This works pretty well, as most of the time error handling is relatively self-contained. Your success case still benefits from await without the error handling forcing weird code structure, but it requires you to add a null check on your data. For more complex async flows, I think this will be easier to read and more intuitive to write. Null checks are easy to forget and may introduce bugs that are easy to miss when writing complex flows.

Because of difficulties handling errors without introducing bugs, I prefer to avoid using async/await on anything that's going to run in the browser. It's an excellent convenience when I don't care about failure cases, but programming is difficult, and programming when errors are swallowed is even harder. There are too many pitfalls to put await into wide use.

What about promises?

When dealing with promises without async/await, the choice for error handling is more straightforward. There are only 2 choices: .catch(), or the second argument to .then(). They have one major difference, which I made a demo for a few weeks ago.

Promises with .catch()

() => {
  fetchData()
    .then(data => {
      doSomethingComplex(data);
    })
    .catch(err => {
      // Errors from `fetchData` and `doSomethingComplex` end up here.
    });
};

This has the same problem as our first try/catch block–it handles errors overzealously. Eventually, when I make a typo while editing doSomethingComplex, I'll lose time because I don't see the error. Instead, I prefer to use the error argument to .then().

  fetchData()
    .then(
      data => {
        doSomethingComplex(data);
      },
      err => {
        // Only errors from `fetchData` are caught.
      }
    );
};

I rarely use .catch(). I want errors from within my success case to propagate up to where I can see them. Otherwise, any problems during development will be swallowed, increasing the odds that I ship a bug without realizing it.

However, I prefer to handle errors very precisely. I prefer to have bugs surface so that they can be observed and fixed. Stopping errors from propagating may be desirable, if you want the UI to keep chugging through any problems it encounters. Be aware, doing so means only serious failures will be logged.

Other problems with promises

A significant "gotcha" that I've run into with promises is that thrown errors within a promise will always cause a rejection. This can be a problem if you’re developing an abstraction over some kind of external data. If you assume that your promise rejection handler only has to handle network errors, you’ll end up introducing bugs. Non-network exceptions won’t make it to your bug tracking tools or will lose important context by the time they do.

const fetchData = () =>
  requestData().then(({ data }) =>
    // What if `removeUnusedFields` throws?
    // It could reference a field on `undefined`, for example.
    data.map(removeUnusedFields)
  );

//
fetchData().then(handleSuccess, err => {
  // This code path is called!
});

This is just how promises behave, but it’s bitten me a few times during development. There isn’t an easy fix for it, so it’s just a case to keep in mind during development. It’s not likely to occur spontaneously in production, but it can cost you time when you’re editing code.

There are always some unknowns when you’re writing code, so it’s safe to assume your error handling will eventually be run with something that it isn’t designed to handle. Imprecise error handling has significant costs in productivity and number of bugs shipped. I encountered an example recently when editing a complex series of asynchronous tasks that used await with try/catch. It threw in the last function call in the try, executing both the success and failure code paths. It took me a while to notice the behavior, and longer to understand why it was happening.

Overall, there are a number of ways that promises can put you into a bad position to handle errors. Understanding how errors will or won’t propagate will help you write code that tolerates faults better. It’s a fine line to tread between handling errors properly and avoiding overly defensive code but it's one that will pay dividends in the long run.

Looking forward, there is a proposal to add pattern matching (it’s stage 1 at time of writing) that would provide a powerful tool for precisely handling errors. Given the varied ways of describing errors used in different parts of the JS ecosystem, pattern matching looks to be an excellent way of describing them.

For more reading about promises, I recommend this post by Nolan Lawson that was sent to me in response to an earlier draft of this post. Interestingly, he suggests avoiding handling errors in .then(), favoring .catch(), and it's good to read different perspectives. It talks much more about composing promises together, something I didn't touch on at all.


Thanks for reading! I'm on Twitter as @cvitullo (but most other places I'm vcarl). I moderate Reactiflux, a chatroom for React developers and Nodeiflux, a chatroom for Node.JS developers. If you have any questions or suggestions, reach out!

Discussion (7)

pic
Editor guide
Collapse
tunaxor profile image
Angel D. Munoz

Thanks for this post, I do like to see people write about async/await/promises
while I agree with most of the post (all of the async/await part) I would add the following to the promises part:

When using promises always return

fetchData()
    .then(data => {
      doSomethingComplex(data); // if this is a promise, the error will be swallowed
    })
    .catch(err => {
      // Errors from `fetchData` and `doSomethingComplex` end up here.
    });

In my opinion every error should be handled in a .catch statement and any asynchronous code inside a promise should be returned, this will prevent error swallowing (which is very common in bad written async code)

if for some reason you have to finish your chain of events sooner you can always chain .then and .catch check this repl for an example on how things can fail

repl.it/@AngelMunoz/PromiseErrorHa...

also I would recommend the following post that gives an awesome explanation of Promises code
pouchdb.com/2015/05/18/we-have-a-p...

Cheers! keep up posting!

Collapse
vcarl profile image
Carl Vitullo Author

Handling errors in .catch() also causes problems when you're chaining promises, as .catch always returns a resolved promise. If you're trying to chain based on the result of a promise, catch() won't behave the way you want.

catch() will swallow errors from non-asynchronous code, which is a very common use case. The code sandbox I link to shows an exception from Redux code being swallowed. I agree that any further promises should be returned, but the behavior of catch() led me to write some bugs when trying to handle errors at each step of my returned promises, rather than handling them all in a catch() at the end.

Collapse
tunaxor profile image
Angel D. Munoz

If you saw the repl I linked, catch does not swallow non async errors and returning the promise in a catch is something meant to let the user handle the program's flow

Not returning inside promises is the real reason stuff gets weird behaviors and error swallowing, I invite you to read the post I linked to, he makes clear these things you try to point out

Thread Thread
vcarl profile image
Carl Vitullo Author

The repl shows you manually throwing in catch() in order to skip the remaining then()s as well as manually returning the data, neither of which seem like a practice I'd like to use. It doesn't appear to show an example of sync code throwing an error either, which my Sandbox demonstrates will be caught. Good feedback though! I'm glad you shared your perspective.

Thread Thread
tunaxor profile image
Angel D. Munoz

The repl shows you manually throwing in catch() in order to skip the remaining then()s as well as manually returning the data, neither of which seem like a practice

Promises are meant to be used in a "synchronous way" because once code becomes async it can't go back to be sync, so passing stuff around is what they are meant to do.
I updated the repl throwing synchronous and asynchronously just comment and uncomment 11 and 10, 17 (which is the synchronous code that will throw synchronously inside a catch) to see what I mean.

It's nice to see different perspectives, cheers!

Collapse
sandorturanszky profile image
SandorTuranszky

Good article. I also made my research on error handling touching all possible error cases and implemented it in my open source project.
dev.to/sandorturanszky/production-...

Collapse
rhymes profile image
rhymes

Thanks for this clear and useful explanation. I love async await but it's starting to feel weird using all these catch all try...catch blocks