loading...
Cover image for Gotchas about async/await and Promises

Gotchas about async/await and Promises

maxart2501 profile image Massimo Artizzu Updated on ・7 min read

JavaScript has always had an asynchronous nature. Most of the web's APIs were synchronous though, but things eventually changed also thanks to functions being first-class citizens in JavaScript. Now, basically every new JavaScript API is designed as asynchronous. (Even the decades-old API for cookies might get an asynchronous re-vamp.)

Problems came when we had to serialize those asynchronous tasks, which means executing an asynchronous method at the end of a callback, and so on. In practice we had to do this:

$.get('/api/movies/' + movieCode, function(movieData) {
  $.get('/api/directors/' + movieData.director, function(directorData) {
    $.get('/api/studios/' + directorData.studio, function(studioData) {
      $.get('/api/locations/' + studioData.hq, function(locationData) {
        // do something with locationData
      });
    });
  });
});

Yes, that's the pyramid of doom. (And that's just a simple case: when you had to execute asynchronous tasks in parallel, that's when things got crazy.)

Then Promises came, together with ES2015. With the... huh, promise to turn our code into this:

doSomething()
  .then(data => doStuff(data))
  .then(result => doOtherStuff(result))
  .then(outcome => showOutcome(outcome));

Nice, easy to read, semantic. In practice, more often than expected, we ended up with something like this instead:

doSomething().then(data => {
  doStuff(data).then(result => {
    doOtherStuff(data, result).then(outcome => {
      showOutcome(outcome, result, data);
    });
  });
});

It's the pyramid all over again! What has happened?!

This basically happens when a task depends not only on the result of the previous one, but also on the results of prior tasks too. Of course, you could do this:

let _data;
let _result;
doSomething().then(data => {
  _data = data;
  return doStuff(data);
}).then(result => {
  _result = result;
  return doOtherStuff(_data, result);
}).then(outcome => {
  showOutcome(outcome, _result, _data);
});

I won't even start to point how awkward and jarring that is. We're declaring the variable we need way before assigning its value, and if you, like me, suffer from OCD of "must-use-const" whenever the value of a variable isn't expected to change, you'll feel those lets as stabs in your pupils.

But then ES2016 came, and it brought the async/await sweetness! That promised (...) to turn our mess into this sync-like code:

const data = await doSomething();
const result = await doStuff(data);
const outcome = await doOtherStuff(data, result);
await showOutcome(outcome, result, data);

Nice!

But... as usual, things aren't always so easy. Let's see.

No promise should be left uncaught

This is especially true, since promise rejections are not thrown errors. Although browsers and Node got smarter in recent times, promises with unhandled rejections used to fail silently... and deadly. Not to mention the mess to debug.

Now, what happens when awaiting a rejected promise?

It throws.

Solving this issue is therefore easy-peasy, you might think. We've had try...catch for eons:

try {
  const data = await doSomething();
} catch (e) {
  console.error('Haha, gotcha!', e.message);
}

... Now, I must ask. How many of you JavaScript developers feel comfortable writing try...catches? JavaScript has always been such a forgiving language that most of the times we just needed to check if a value was null or something like that. Add that JavaScript isn't quite performant when dealing with try...catch, and you have a recipe for an awkward reaction.

(Although in recent times things have changed a bit. While before V8 didn't optimize code inside try...catch, it's not the case anymore with V8 6.0 and TurboFan shipped with Chrome 60 and Node 8.3, and I guess other browser vendors will catch up soon. So we'll end up with the usual performance problems of native Promises.)

Scoped woes

Ok, we had to change our nice await one-liners with 5 lines of try...catch. That's already bad enough, but unfortunately it's not all. Let's examine again the code:

try {
  const data = await doSomething();
} catch (e) { ... }

// Doing something with data...

Well, we're out of luck again: we can't use data because it's out of our scope! Its scope, in fact, lives only inside the try block! How can we solve that?

... And the solution is, again, ugly:

let data;
try {
  data = await doSomething();
} catch (e) { ... }

// Doing something with data...

Again with pre-declaring a variable with let... One is almost compelled to use var again! And actually it won't be that bad, since with async/await your functions will probably have a flat scope and your variables will have a closure scope anyway. But linters will tell your code sucks, your OCD won't let you sleep, coffee will taste sour, kittens will get sad and so on.

The only progress we've made is that we can use let right before the try...catch block, so things are a little less jarring:

let data;
try {
  data = await doSomething();
} catch (e) { ... }

let result;
try {
  result = await doStuff(data);
} catch (e) { ... }

The PokΓ©mon solution

If you care about kittens being happy, you need to do something. Here's the common, easy, f-that-I-ve-stuff-to-do way:

try {
  const data = await doSomething();
  const result = await doStuff(data);
  const outcome = await doOtherStuff(data, result);
  await showOutcome(outcome, result, data);
} catch(e) {
  console.error('Something went wrong, deal with it πŸ•ΆΒΈΒ', e.message);
}

Let me tell you, you still won't get sleep. Yes, you "gotta catch 'em all", but not like that. You've been taught countless of times that this is bad and you should feel bad, especially in JavaScript where you can't rely on multiple catch blocks for telling exception types apart, and instead you have to check them with instanceof or even the message property.

Do by the book

You pinky-promise that you'll never do that and do things as they should be. Likely scenario:

try {
  const data = await doSomething();
  const result = apparentlyInnocentFunction(data);
  return result;
} catch(e) {
  console.error('Error when doingSomething, check your data', e.message);
}

We're catching rejected promises, that's right. But what's happening after that? Nothing much, we're just calling an innocent (apparently) function to transform the data.

... Are we sure about that? Is that function all that innocent?

The problem is that a try...catch is still a try...catch. It won't just catch out awaited promises, it will catch all the thrown errors, whether we expect them or not. To do things correctly, we should use try...catch to wrap just the awaited promise.

Ugly. Verbose. Painful. But necessary.

And we've already seen this when just using Promises, so this shouldn't be new. In short, don't do this:

doSomething.then(data => {
  const result = apparentlyInnocentFunction(data);
  return result;
}).catch(error => {
  console.error('Error when doingSomething, check your data', e.message);
});

Do this instead:

doSomething.then(data => {
  const result = apparentlyInnocentFunction(data);
  return result;
}, error => { // <= catching with the second argument of `then`!
  console.error('Error when doingSomething, check your data', e.message);
});

A good compromise?

So, how can we deal with this mess? A nice solution would be getting rid of try...catch blocks altogether and taking advantage of Promises and remember that they have a catch method on their own that returns a Promise again. Here we are:

const data = await doSomething()
    .catch(e => console.error('Error when doingSomething', e.message));
if (!data) { /* Bail out somehow */ }

Personally, I've mixed feelings about this. Is it nicer? Are we mixing techniques? I guess most of this depends on what we're dealing with, so here you are.

Just keep in mind that:

  • await doesn't just resolve Promises, but any object that has a then method - a thenable (try this: await {then() {console.log('Foo!')}});
  • more than that, you can await any object, even strings or null.

This means that then or catch might not be defined, or not what you think they are. (Also remember that .catch(f) is sugar for .then(null, f), so the latter is all you need to define a thenable.)

Hidden parallelism

How to resolve multiple parallel (or better, concurrent) promises all at once? We've always been relying on Promise.all:

Promise.all([ doSomething(), doSomethingElse() ]).then(...);

// or in terms of await:
await Promise.all([ doSomething(), doSomethingElse() ]);

But Cory House has recently given this tip:

So it's possible to resolve concurrent promises without it too:

const a = doSomething();
const b = doSomethingElse();
// Just like await Promise.all([a, b])
await a, await b;

The trick here is that the promises have been initiated before being awaited. Awaiting the function calls directly instead of a and b would have resulted in serialized execution.

My suggestions here are: watch out for these possible concurrency problems; and don't be "clever" and try to exploit this. Using Promise.all is much clearer in terms of readability.

Not just sugar

You might have heard that async/await is, like many other new features of JavaScript, just syntactic sugar for something you could already do with the classic ES5 JavaScript. It's mostly true but, just like many other cases (classes, arrow functions, etc.), there's more to it.

As Mathias Bynens recently pointed out, the JS engine must do a lot of work to get a decent stack trace out of Promise chains, so using async/await is undeniably preferable.

The problem is that we can't just use it as we wish. We still have to support older browsers like IE or Node 6.x that don't support the new syntax. But let's not overlook browsers like UC and Samsung Internet that don't support it either! In the end we'll have to transpile it all, and will do that for a while too.

Update (March 2018): Samsung Internet and UC Browser now both support async/await, but watch out for older versions.

Conclusions

I don't know yours, but my experience with transpiled async functions has been... less than optimal so far. It looks like Chrome has some bugs dealing with sourcemaps, or maybe they're not well defined, but whatever.

Do I use async/await? Yes, certainly, but I think I'm not using it as much as I'd like due to all the mentioned problems. It's definitely the future, but a future that has to be taken with a grain of salt.

What's your experience with async/await?

Posted on by:

maxart2501 profile

Massimo Artizzu

@maxart2501

Senior web developer πŸ”₯ ~ conf speaker πŸŽ™οΈ ~ loves science πŸ”­, art 🎨, rugby πŸ‰ ~ reinventing a better wheel 🎑

Discussion

markdown guide
 

I switch between async/await and plain promises often. I also mix await with .catch()

Some code gets simpler with await, especially when I need to mix results of multiple promises and following requests are based on the results of the last ones.

const user = await getUser();
const posts = await getPosts(user.id);
return posts.map(p => ({...p, author: user.name});

Some code gets simpler with plain promises, like parallelisation. When I need to retrieve the data of multiple views in one screen I often drop them off as promise and then the result into the views when they arrive.

this.setState({docsLoading: true, foldersLoading: true});
getDocs().then(docs => this.setState({docs, docsLoading: false}));
getFolders().then(folders => this.setState({folders, foldersLoading: false}));
 
doSomething().then(data => {
  doStuff(data).then(result => {
    doOtherStuff(data, result).then(outcome => {
      showOutcome(outcome, result, data);
    });
  });
});

Instead of this you can do something like the following:

const concat = _.curry(_.concat, 2)
doSomething()
  .then(data => 
    doStuff(data).then(concat(data))
  .then([data, result] =>
    doOtherStuff(data, result).then(concat([data, result]))
  )
  .then([data, result, outcome] =>
    showOutcome(data, result, outcome)
  )
 

Or like this:

doSomething()
  .then(data => Promise.all([data, doStuff(data)]))
  .then(([data, result]) => Promise.all([data, result, doOtherStuff(data, result)]))
  .then(([data, result, outcome]) => showOutcome(data, result, outcome));

Clean and simple approach, but passing around of all the values is quite cumbersome.

 

My main point was that you can still get a "flat" result when you have interdependent calls.

You can also pull this off with callbacks.

 

The 'tip' from Cory House that you shared in this article is an anti pattern that should be avoided.

If the doSomething() and doSomethingElse() from your example both throw, you will get an unhandled exception warning - only the first exception will be caught. This can lead to hard-to-debug warnings.

Promise.all is the safe way to execute multiple async processes in parallel.

 

Very interesting! I'm not an expert in javascript and I have faced this callback hell sometimes.
I use the caolan/async module to solve this in nodejs and also for web pages, is there any problem I'm missing? Like loosing too much performance or other things?

 

Nice post. I've had a bit of a modern JS phobia I'm just starting to shake and this helps.

 

This is an eye-opener. Also, my kittens like this post. ;) :)