DEV Community

loading...
Cover image for About async functions

About async functions

Dominik D
・5 min read

Async functions are great, especially if you have to call multiple functions in a row that return promises. With async / await, code becomes easier to reason about for humans, because the data flow mimics synchronous code, which is what we are used to reading.

So what are async functions exactly?

Syntactic sugar

When I first learned about async functions, the following sentence stuck with me:

Async / await is "just" syntactic sugar for promise chaining

— Someone, somewhen

This is mostly true, and if that's your mental model about async functions, it will get you quite far. To re-iterate, let's take an example and refactor it from promise chaining to an async function:

function fetchTodos() {
  return fetch('/todos')
    .then(response => response.json())
    .then(json => json.data)
}
Enter fullscreen mode Exit fullscreen mode

So far, so good. Nothing too difficult here, just our normal data fetching and extracting (error handling left out intentionally here). Still, even with this example, the callbacks are not so easy to read, so how would this look with an async function?

async function fetchTodos() {
  const response = await fetch('/todos')
  const json = await response.json()
  return json.data
}
Enter fullscreen mode Exit fullscreen mode

Ah, I believe that reads a lot better, because you can actually see where we are assigning variables to and what will be the final return value of that function.

So, if that is a good mental model for async functions, what's the problem with the above definition? Well, it's just not everything. There are a couple of subtle difference between promise chaining and async functions that I learned the hard way. Let's go through them:

They always return a promise

This is actually the defining trait of an async function. No matter what you do, it will always return a promise, even if you don't explicitly return one:

async function fetchRandom() {
  // ✅ this will return `Promise<number>`
  return Math.random()
}
Enter fullscreen mode Exit fullscreen mode

This is necessary because you can use the await keyword in async functions, and once you do that, you enter promise-land, in which there is no escaping from. If code is async, you can't turn it back to sync code. I was personally quite confused by this, because in scala, Await.result actually takes a promise, blocks the execution for a certain amount of time and then lets you continue synchronously with the resolved value.

In JavaScript however, an async function will stay asynchronous, so the return value must be a promise, and the language construct makes sure of this out of the box. This brings us to the next point:

It transforms thrown Errors into rejected promises

You might have seen this in example code involving the fetch API, as fetch will not automatically give you a failed promise on erroneous status codes like other libraries, e.g. axios, do. To get to a failed promise, you just throw an Error (or anything, really), which will then be transformed into a failed promise. This is happening because, again, an async function always needs to return a promise:

async function fetchTodos() {
  const response = await fetch('/todos')
  if (!response.ok) {
    // ✅ this will become a failed promise
    throw new Error('Network response was not ok')
  }
  return response.json()
}
Enter fullscreen mode Exit fullscreen mode

Now the same works if you are in a promise chain, but not if you are outside of it. Suppose you want to do some parameter validation and decide to throw an Error if the input is invalid in a non-async function:

function fetchTodo(id: number | undefined) {
  if (!id) {
    // 🚨 this will NOT give you a failed promise
    throw new Error("expected id")
  }
  return fetch('/todos')
    .then(response => response.json())
    .then(json => json.data)
}
Enter fullscreen mode Exit fullscreen mode

If you make the same function async, it would give you a failed promise. These little nuances can be quite confusing, so I prefer to explicitly work with Promise.reject no matter which context I'm in:

function fetchTodo(id: number | undefined) {
  if (!id) {
    // ✅ this will work as expected, no matter where
    return Promise.reject(new Error("expected id"))
  }
  return fetch('/todos')
    .then(response => response.json())
    .then(json => json.data)
}
Enter fullscreen mode Exit fullscreen mode

They always return a new promise

I first stumbled upon this when working with query cancellation in react-query. Here, react-query wants us to attach a .cancel method on our resulting promise. Surprisingly, this doesn't quite work in async functions:

async function fetchTodos() {
  const controller = new AbortController()
  const signal = controller.signal

  const promise = fetch('/todos', {
    signal,
  })

  promise.cancel = () => controller.abort()
  // 🚨 This will be a new promise without the cancel method!
  return promise
}
Enter fullscreen mode Exit fullscreen mode

Because we are in an async function, a new promise will be returned at the end of it, even if we already return a promise ourselves! Here is a great article if you want to see how query cancellation can work even with async functions.

Handling errors

The default way of handling errors in async functions is with try / catch, which I don't like very much, mainly because the scope of try / catches seems to get very large. If additional, synchronous code happens after the async operation that might fail, we are likely still treating it as if the fetch failed:

const fetchTodos = async (): Promise<Todos | undefined> => {
  try {
    const response = await axios.get('/todos')
    // 🚨 if tranform fails, we will catch it and show a toast :(
    return transform(response.data)
  } catch (error) {
    showToast("Fetch failed: " + error.message)
    return undefined
  }
}
Enter fullscreen mode Exit fullscreen mode

Sometimes, we even silently catch and discard the error, which will make debugging very hard.

So if you also think that async / await is cool, but try / catch is not, you can try combining async functions with "traditional" catch methods:

const fetchTodos = async (): Promise<Todos | undefined> => {
  const response = await axios.get('/todos').catch(error => {
    // 🚀 showing the toast is scoped to catching the response error
    showToast("Fetch failed: " + error.message)
    return undefined
  })
  return transform(response?.data)
}
Enter fullscreen mode Exit fullscreen mode

In summary

I hope this gives you a bit of a deeper understanding of what async / await is doing under the hood. I have seen lots of code where the async keyword is just stuck on a function for no good reason, so lastly, here are some examples of patterns that I think should be avoided:

// 🚨 the async keyword doesn't do anything -
// except creating a new unneccessary promise
const fetchTodos = async () => axios.get('/todos')

const fetchTodos = async () => {
  const response = await axios.get('/todos')
  // ⚠️ awaiting a non-promise is possible, but doesn't do anything
  return await response.data
}

// 🙈 I don't even 🤷‍♂️
const fetchTodos = async () =>
  await axios.get('/todos').then(response => response.data)

const fetchTodos = async () => {
  try {
    // 🚨 await is redundant here, too
    return await axios.get('/todos')
  } catch (error) {
    // 🚨 the catch-and-throw is totally unnecessary
    throw error
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below ⬇️

Discussion (18)

Collapse
lukeshiru profile image
LUKESHIRU

Excellent article, Dominik! My personal preference is to only use async/await when it actually makes code more simple (like with async iterators), if not then I just default to using promises directly. More often than not, I found articles here in dev.to where they show a piece of code using async "because of reasons", instead of just using a simple .then or not using promises at all.

Thank you for taking the time of putting this together :D

PS: Just for the lols, an image of a person using async/await:
Because of reasons

Collapse
tkdodo profile image
Dominik D Author

Thank you, happy that you like it :)

Collapse
thorstenhirsch profile image
Thorsten Hirsch

Actually in your first example I find the then() based approach much more readable than the async/await approach, because the data is being chained or piped through the lines. I also think about it as a more functional way, whilst async/await is the procedural way.

I wouldn't always prefer then() over async/await. The latter is especially more suitable when there's just a single promise.

Collapse
tkdodo profile image
Dominik D Author

Yep, it's definitely a stylistic thing from time to time. To be honest, if you use TypeScript, the types flow through .then chains very nicely as well, so I don't really have a problem with that. I just don't really like that people sometimes stick async on a function for no apparent reason, possibly without knowing what it does and what it's for, so that was my main motivation to write this blog post :)

Collapse
mike4040 profile image
Mike Kravtsov

I think important to add that async / await doesn’t work inside Array built in iterator functions, like .forEeach.
What is really bad that this behavior often not mentioned in tutorials, not get caught by linters, and didn’t throw error.
Good news is that there is a way to make it work, but it’s a topic for another article:)

Collapse
tkdodo profile image
Dominik D Author

good point. Am I right in thinking that it works with awaiting Promise.all of the result array, if used with .map instead of with .forEach ?

Collapse
lukeshiru profile image
LUKESHIRU

Yup, and you can also use Promise.allSettled. Usually way cleaner than doing a for..of with async and await all over the place :D

Thread Thread
mike4040 profile image
Mike Kravtsov

Or .reduce, starting with Promise.resolve().
The issue is not complicated, easy to find a few good solutions on StackOverflow.

Thread Thread
miketalbot profile image
Mike Talbot • Edited

Worth just pointing out that for...of and reduce cause the promises to be started in sequence where the all variants start them without waiting for the earlier ones to finish.

My 2 cents: for...of lets you chain promises and terminate early, that's my way to go if that's my need or if the reduce function would be long, as I find that more readable in this circumstance.

Collapse
leandroandrade profile image
Leandro Andrade

Be careful when using Promise.all because if one Promise fails in the Promises set, the others will still run.

Collapse
alecvision profile image
alecvision

I thought I had a pretty good handle on async JS, but the 'I don't even...' example at the end has me a little confused. Could you please detail the antipattern you're illustrating there?

I've even been using promises with arrays, async mapping and reducing to my heart's content... but just because it 'works' doesn't mean I necessarily understand the nuances of HOW.

(self taught, JS first language outside of bash)

Collapse
tkdodo profile image
Dominik D Author

it's multiple things here really:
1) combining async/await with .then(). I think you'd want to choose one way and then stick to it
2) awaiting the last (and in this case only) Promise is unnecessary. You can just return the Promise, instead of awaiting it and then returning a new Promise due to the nature of an async function.
3) Since the await is unnecessary (see point 2), unless you get rid of the the .then() chain (see point 1), the function being async is also unnecessary.

All in all, that combination is just unnecessarily verbose and shows that whoever has written it doesn't understand what async functions are really doing :)

Collapse
alecvision profile image
alecvision • Edited

I'll be doing a lot more reading up on all of this now that I think I'm scraping at what you're saying...

My code has consistently worked up to a recent project - I wrote a function to update state which returns successfully but doesn't produce the desired side-effects. I'm almost certain now it has to do with an unresolved promise somewhere.

I actually scrapped it and chose a different approach, fearing it might be an API bug with the component since I had successfully implemented a similar solution elsewhere in my app.

Thanks for your post and reply - I'm just now getting to the level of confidence to even reach out to other developers, so I really appreciate your thoughtful response!

EDIT: I took one 'await' out of my init function just to see if I was understanding things right - and it still works, but faster by a factor of 3! Thanks again :D

Thread Thread
tkdodo profile image
Dominik D Author

Just saw your edit and that’s incredible 🙌

Collapse
alecvision profile image
alecvision

Taking an early guess, is it the combo of async/await and .then()? That doesn't feel quite right tho, because await only works in async functions so I imagine you have to wrap the .... oh wait, my logic only makes sense if the callback inside .then() is also async. The resolved value of the getter is equivalent to the parameter passed to the callback, unless that callback returns a promise (which, in that case, is what you'd be awaiting)

Collapse
jcubic profile image
Jakub T. Jankiewicz

I think that your example after "If you make the same function async, it would give you a failed promise." should have async in code. Both examples are the same.

Collapse
tkdodo profile image
Dominik D Author

The next example replaces throw with Promise.reject, both in non-async functions on purpose. The takeaway is that throw is only consistently transformed to failed promises in async functions, which is a detail that is easy to miss. Promise.reject is more explicit and works everywhere.

Collapse
jcubic profile image
Jakub T. Jankiewicz

That's right, sorry didn't nice that. I was looking at missing async keyword. Good article.