loading...
Cover image for The dangers of async/await

The dangers of async/await

christopherkade profile image Christopher Kade Originally published at christopherkade.com ・3 min read

After a few months consulting on the rewriting of a large-scale application, I've come to realize that async/await was used de facto for most asynchronous operation and parallel executions seemed to be out of the picture. For example, consider this Vue code snippet:

 async initStore(query) {
    await this.getConfig();
    await this.getUser();
    await this.checkRussianContext(query);

    await this.getBasket(this.$store.state.config.selectedCurrency),

    await this.$store.dispatch('options/fetchOptions', {
      basket : this.$store.state.basket,
    });
 },

Here, each line of code is executed when its predecessor is completed. Meaning getUser will wait for getConfig to finish fetching data before being executed.

Here are a few points that come to mind when seeing this snippet:

  • What if one line does not need data from the previous one? Why block its execution and slow down our application?
  • Could we run unrelated methods in parallel using something like Promise.all?
  • Related methods should probably be using a then block to avoid blocking the rest of the method

The point this article will be to help you catch this code smell by showing you that using async/await by default in some cases can have a drastic impact on performance and UX.

Unrelated queries should be executed in parallel

Let's see some concrete data, shall we?

Here's the code snippet we'll be analyzing:

const getUserData = async () => {
  // Get a random dog as our user's avatar
  const res = await fetch('https://dog.ceo/api/breeds/image/random')
  const { message } = await res.json()

  // Get our user's general data
  const user = await fetch('https://randomuser.me/api/')
  const { results } = await user.json()

  // ...
}

Running this snippet 100 times on fast 3G (using Chrome's dev tools), the average execution time is 1231.10ms.

But why block the second query when it doesn't need the result of the first? Let's change our code to the following and re-run it 100 times.

const getUserDataFaster = async () => {
  // Execute both requests in parallel
  const [res, user] = await Promise.all([
    fetch('https://dog.ceo/api/breeds/image/random'), 
    fetch('https://randomuser.me/api/')
  ])
  const [{ message }, { results }] = await Promise.all([res.json(), user.json()])

  // ...
}

We now have an average execution time of 612.50ms, half the time needed when both queries were executed one after the other.

The point is: if you can execute time-consuming queries in parallel, do it.

Try it out yourself on this codepen.

Unrelated code should not have to wait

Let's take my first example but with a twist:

 async initStore(query) {
   await Promise.all([
     this.getConfig(),
     this.getUser(),
     this.checkRussianContext(query)
   ])

   await this.getBasket(this.$store.state.config.selectedCurrency),

   await this.$store.dispatch('options/fetchOptions', {
     basket : this.$store.state.basket,
   });

   await initBooking()
 },

Here, the first 3 requests are executed in parallel, whereas the next ones rely on data fetched beforehand and will therefore be executed afterwards. Although this snippet poses a problem, did you spot it?

Poor little initBooking will have to wait for both getBasket and fetchOptions to finish before executing even though it has nothing to do with the data they'll fetch.

An easy solution is to trade the await with a simple then block.

  async initStore(query) {
    await Promise.all([
      this.getConfig(),
      this.getUser(),
      this.checkRussianContext(query)
    ])

    this.getBasket(this.$store.state.config.selectedCurrency).then(async () => {
      await this.$store.dispatch('options/fetchOptions', {
        basket : this.$store.state.basket,
      });
    })   

   await initBooking()
 },

This way, both getBasket and initBooking will be executed alongside one another.

Want to see it for yourself? Check out this codepen illustrating my example.

I'll stop the article there so I don't overload you with examples, but you should get the gist of it by now.

async/await are wonderful additions to the Javascript language but I hope you'll now ask yourself if they have their place in the specific method you're working on and more importantly: if some of your queries could be executed in parallel.

Thank you for reading, I'd love it if you gave me a follow on Twitter @christo_kade, this way we'll get to share our mutual skepticism towards awaits ❤️

Discussion

pic
Editor guide
Collapse
lucis profile image
Lucis

async/await are (almost) syntatic sugar for the plain-old Promise API, you could accomplish the same "blocking" behaviour using promise1().then(promise2).then(promise3) and so on, thus this is not a problem with async/await syntax per-se, IMO.

One using await should first know about the Promise API, so it'll know better how to use it

Collapse
christopherkade profile image
Christopher Kade Author

Absolutely, and even when knowing these tools some developers tend not to prioritize parallel executions for some reason. A colleague said the same thing as you after reading it, and it's very pertinent.

Thanks Luciano !

Collapse
lucis profile image
Lucis

I would suggest you to write a post of "How to parallelize your work with async/await". There's a nice feature that is something like:

const p1 = promise1()
const p2 = promise2()
const r1 = await p1
const r2 = await p2

If I'm not mistaken, this way you 1) use async/await, what's really nice 2) let the promises run right-away

Thread Thread
xxaccexx profile image
acce

Promise chains are a problematic solution, as each '.then' produces another promise. What happens if an earlier 'then' throws and there's no catch or other error handling? Memory leak

Collapse
lukaszahradnik profile image
Lukáš Zahradník

Hi,
your usage of Promise.all is wrong. You are using Promise.all to achieve parallel execution, but at the same time you are awaiting all of the passed promises, which leads to sequential execution.

await Promise.all([
    await this.getConfig(),
    await this.getUser(),
    await this.checkRussianContext(query)
])

You should not await promises to achieve parallel execution. So right way would be like this

await Promise.all([
    this.getConfig(),
    this.getUser(),
    this.checkRussianContext(query)
])
Collapse
christopherkade profile image
Christopher Kade Author

Absolutely, thanks for catching my mistake. Must have made it while pasting my notes !

Collapse
nudelx profile image
Alex Nudelman

Absolutely! But there is a problem. If one of those will fail it will break all promise.all() iteration and you will be thrown into the catch. So I if you need/want to run fully async and parallel and get all results (errors and values) regardless of the failures you should use this:

 const promiseAllWithAllErrors = function(mapAsync) {
  return Promise.all(
    mapAsync.map(f =>
      f.then(r => Promise.resolve(r)).catch(e => Promise.resolve(new Error(e)))
    )
  )
}


promiseAllWithAllErrors([
   this.getConfig(),
    this.getUser(),
    this.checkRussianContext(query)
])

let's test it with some timeout function

const fun = function(txt) {
  return new Promise((yes, no) => {
    setTimeout(function() {
      if (txt === '2' || txt === '3') {
        no(txt + ' error')
      } else {
        yes(txt)
      }
    }, 5000)
  })
}

 promiseAllWithAllErrors([
  fun('1'),
  fun('2'),
  fun('3'),
  fun('4')
]).then(console.log)

output:

time node /tmp/t.js 
[ '1', Error: 2 error, Error: 3 error, '4' ]

real    0m5.205s.    ==> only 5 sec , instead of 5*4 and all answers in one array without interuptions  
user    0m0.071s
sys     0m0.044s
Thread Thread
lukaszahradnik profile image
Lukáš Zahradník

Well in that case you would use Promise.allSettled, which is basically what you implemented.

Thread Thread
nudelx profile image
Alex Nudelman

yea but it still in implementation progress, kinda experimental.

developer.mozilla.org/en-US/docs/W...

Collapse
matthisd profile image
Matthis Duclos

I think your article points out the fact we need to parallelize calls that don't depend on each other. I already read some code using promises that could have been parallelized too.

One of the advantages about async/await (outside the fact that the code may be easier to read) is that it can improve stacktraces. To know more about this point, I encourage you to read this blog post from Mathias Bynens

Collapse
jmfayard profile image
Jean-Michel Fayard 🇫🇷🇩🇪🇬🇧🇪🇸🇨🇴

well the trade-off here is that by using parallelism, you will probably spend more time coding and debugging your code.
I think it's fine to do the simple thing by default and parallelize only when you have measured that it's worth it

Collapse
christopherkade profile image
Christopher Kade Author

Agreed, to some extent. If you have a clear way of determining which query failed and why, it shouldn't be taking you more time to debug whenever one of them throws an error.

As you said, it must be a calculated risk.

Collapse
jmfayard profile image
Jean-Michel Fayard 🇫🇷🇩🇪🇬🇧🇪🇸🇨🇴

It depends, if you have two http queries that are totally independant OK, but if you have a number of asynchronous calls that do side effects, it's much harder to reason about it if you add parallelism

Thread Thread
christopherkade profile image
Christopher Kade Author

That's a good point yeah, it can make it much harder to determine the cause of everything falling apart. It really should be done in the right circumstances.

Thread Thread
mx profile image
Maxime Moreau

Async with side effects is my nightmare :(

Collapse
integerman profile image
Matt Eland

This is kind of funny. I was planning on writing an article this evening with something pretty close to this title, but focused on C# code.

Different points, but... still, I'll have to rethink that or at least the title.

Collapse
christopherkade profile image
Christopher Kade Author

I'd love to read your take on it either way :) sorry for beating you to it haha

Collapse
rolfstreefkerk profile image
Rolf Streefkerk

The title is just a bit misleading, it's more a discussion about parellelism vs serial execution.
I agree, if you don't take the program flow into account you can horribly increase application latency.
Good post

Collapse
khrome83 profile image
Zane Milakovic

What a great write up. This is super valid code smell. I forget about this a lot when I am writing code with asynchronous/await first pass.

But when I do promises, I typically don’t. I never tried the promise with async/await like that. It’s clever and I am going to use it. Well done!

Collapse
layzee profile image
Lars Gyrup Brink Nielsen

The most overlooked caveat of async-await is that we have to wrap it in a try-catch block to handle errors by catching rejected promises.

Collapse
thibmaek profile image
Thibault Maekelbergh

I don't see why this would be considered a caveat? Thenable promises also need to explicitly declare a Promise#catch() to handle errors? What makes it different from wrapping async/await in try-catch?

Collapse
layzee profile image
Lars Gyrup Brink Nielsen

I mean it's something to be aware of and it's often overlooked and not mentioned at all in articles discussing and guiding on async-await.

The difference between this and promises is that catch callbacks can be put specifically in any place in a promise chain. Of course, we can also do this with try-catch around a single statement, but that gets ugly pretty fast, especially if we want to keep the same variable scope between related try-catch blocks.

Collapse
yawaramin profile image
Yawar Amin

Christopher, great and timely reminder of the danger of async. However I may be missing something but, about your last example:

Although this snippet poses a problem, did you spot it? ... Poor little initBooking will have to wait for both getBasket and fetchOptions to finish before executing even though it has nothing to do with the data they'll fetch.

None of these calls are fetching any data, at least in the code as shown. They can all be run in parallel with Promise.all...

Collapse
christopherkade profile image
Christopher Kade Author

None of these calls are fetching any data

It's an example, the methods in question could be fetching data.

For example getBasket could fill the store's basket state variable needed by fetchOptions. I simply wasn't going to show the contents of these methods as they don't seem relevant to the point I'm making.

Thank you for your feedback Yawar !

Collapse
mesaquen profile image
Mesaque Francisco

First of all, thanks for the article. I really enjoyed it, but something is bugging me, though.

When you say that "unrelated code shouldn't wait...", I think that there is something we're not addressing here, which is:

Unrelated code shouldn't be in the same function.

My approach on that would be:

1 - Split each block of unrelated code into its own async function. That will keep scope smaller and easy to keep track of what is happening at that moment.
2 - The main function would end up being a point where each step (each block of unrelated code) of the process are invoked in a meaningful order.

Collapse
christopherkade profile image
Christopher Kade Author

Of course, but we must take into account use-cases where our code is running inside a hook for example (mounted, created etc.), or when dealing with legacy code.

The right thing to do would be refactoring it to fit what you mentioned, but we don't always have that option.

Collapse
jaunkst profile image
Jason Aunkst

async/await is more often used when it should not be.

The async/await control flow is an abomination to me. I already have beef with Promises, and async/await just takes it to far.

I have fixed too many bugs in other people's code because they do not want to deal with async programming and instead just try to async-await their way through a feature.

Observables > Promises > Async/Await

Collapse
msosto profile image
Mariano

The "danger" which you are referring is not a async/await thing. No matter which language/framework you are using, it doesn't make any sense to execute sequentially independent api calls. This applies to Java, nodejs, C or whatever language we are talking.

Collapse
blacksonic profile image
Gábor Soós

Why do you put await inside Promise.all array? It is not needed.

Collapse
christopherkade profile image
Christopher Kade Author

Great catch, I must have made a mistake while pasting from my notes. Thanks!

Collapse
eugeneherasymchuk profile image
Yevhenii Herasymchuk

Here, the first 3 requests are executed in parallel, whereas the next ones rely on data fetched beforehand and will therefore be executed afterwards. Although this snippet poses a problem, did you spot it?

Poor little initBooking will have to wait for both getBasket and fetchOptions to finish before executing even though it has nothing to do with the data they'll fetch.

I didn't get the message the author wanted to bring in Unrelated code should not have to wait section. That's exactly why async/await is cool and for sure not danger.

If you want to run them in parallel - you're going with Promise.all ( of course, knowing about the first-fail behavior), you may operate each call separately as well with a personal error handling.
Don't want to say anything bad, just didn't get the info from the section

Collapse
christopherkade profile image
Christopher Kade Author

If you want to run them in parallel - you're going with Promise.all

Absolutely, if it's a habit you've developed as a developer. The point of this article being to help some beginners catch these sections that could be helped with something like Promise.all.

The example with initBooking is my abstract way of showing a bad use of a single awaited method.

Collapse
eugeneherasymchuk profile image
Yevhenii Herasymchuk

Agree, Christopher, thanks for the work. Actually benchmarks are awesome!
Just the title "dangers" makes it quite judging without actual arguments :)
P.S. You know how to make clickbait titles

Thread Thread
christopherkade profile image
Christopher Kade Author

I love to spook my readers first and then relieve them when they understand that it's not that big of a deal haha.

Maybe the word side-effect would have been more interesting in this case. As in the side-effect of over-using this feature.

Collapse
mikgross profile image
Mikael

When I started webdev I had a tendency to make sure everything was loaded before starting to display things. A few years later I took a look at my code, horrified..

Collapse
jochemstoel profile image
Jochem Stoel

Hey this was pretty interesting.

Collapse
leob profile image
leob

Great points, it's all too easy to forget that async/await is just syntactic sugar on top of promises but still the same semantics under the hood.

Collapse
adityathebe profile image
Aditya Thebe

This post is basically explaining how async/await works. Anyone with basic knowledge of async/await knows this. The title just seems like a clickbait to me.

Collapse
christopherkade profile image
Christopher Kade Author

That's not a fair statement. I've tried to show good practices and how to avoid certain code smells based on previous experiences.
Sorry you feel that way.

Collapse
artoodeeto profile image
aRtoo

I agree with this. Even nodejs has it on their guide.