A Deeper Look Into Async/Await

Supported in Node.js since v7.6, Async/Await is hands down one of the best features to come out of an ECMAScript release. And because it changes a lot the way we write asynchronous code, some new patterns - and confusions - emerge along with it.

The next sections introduce a few concepts and “gotchas” I’ve come across after a while writing code using Async/Await.

1. Top-level await

Awaiting promises can’t be done top level.

This means the await operator cannot be used in the top level of your code. It always have to be wrapped in an async function (a function with the async keyword on the beginning).

The example below shows how to do it using an IIFE:

1
2
3
4
5
6
7
8
9
10
11
import { get } from 'axios'
const url = `https://api.github.com/users/ruanmartinelli/repos`
// wrong, throws `SyntaxError`
const { data : myRepos } = await get(url)
// works
(async () => {
const { data : myRepos } = await get(url)
})()

In short, allowing top-level await could cause unexpected behaviours while loading modules on your code (the thing when you do import x from 'x').

The debate is not new, folks have been talking about top-level await even before Async/Await got accepted into Stage 3 of the ECMAScript spec process. There’s more on these links if you want to dig deeper on the subject: [1], [2], [3]

And if you are feeling more adventurous, you can try this.

2. AsyncFunction

Every function that begins with the async keyword is an AsyncFunction. An AsyncFunction always returns a Promise.

That is very fundamental and very important knowing. Any value that you return in an async function will be thenable, you don’t have to worry about using the the Promise constructor or calling Promise.resolve yourself.

1
2
3
4
5
6
7
8
async function getNumberFive(){
return 4 + 2 - 1
}
const result = getNumberFive()
console.log(typeof result.then)
// => [Function: then]

If an error is thrown inside of an async function, the Promise will be rejected as well. See more about async functions on the MDN docs.

3. return await

Since async functions always wrap your results in Promise.resolve, it is redundant to return a value with the await operator.

Take a look at the function below:

1
2
3
4
5
6
async function getJSDevs(){
const filter = { lang: 'javascript' }
// "await" here is redundant
return await getAllDevs(filter)
}

Internally, what you’re doing is wrapping getAllDevs in Promise.resolve twice: one from using an async function and the other from the await operator. return await doesn’t actually do anything more than adding an extra time before the overarching Promise resolves or rejects.

4. Multiple Asynchronous tasks and Loops

Using await inside of a Array.forEach or Array.map callback is tricky and probably won’t work as you expect.

Consider the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { get } from 'axios'
;(async () => {
const users = [
'ruanmartinelli',
'torvalds',
'douglascrockford'
]
let repoCount = 0
users.map(async user => {
await get(`https://api.github.com/users/${user}/repos`)
repoCount++
})
console.log(repoCount)
// => 0
})()

While this is valid JavaScript, the code above will not perform the requests in sequence (one request at a time) like many would probably expect.

Array.map - and also Array.forEach - are not expecting an async function as its callback parameter and will just fire your function as if it were synchronous.

This results in all calls to get being fired and executed in parallel. The console.log will also run immediately and print 0 since it is much faster than any of the HTTP requests.

Besides that, wouldn’t it be odd if a child function (the callback on the example) was able to control the flow of its parent?

To run tasks in sequence the right tool is the for..of statement. We could rewrite the example above like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { get } from 'axios'
;(async () => {
const users = [
'ruanmartinelli',
'torvalds',
'douglascrockford'
]
let repoCount = 0
for(const user of users){
// flow stops here until the request is finished
await get(`https://api.github.com/users/${user}/repos`)
repoCount++
}
console.log(repoCount)
// => 3
})()

Looks much cleaner and the requests will all execute in sequence.

The console.log is going to be executed only after the last iteration of the for..of and will print the number 3.

Another common use case is when you want to execute an array of asynchronous tasks in parallel and only run code after the last task finishes. For that we would use Promise.all, a function that turns an array of promises into a single promise that we can await on.

We can then rewrite our code like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { get } from 'axios'
;(async () => {
const users = [
'ruanmartinelli',
'torvalds',
'douglascrockford'
]
let repoCount = 0
await Promise.all(
users.map(user => {
repoCount++
return get(`https://api.github.com/users/${user}/repos`)
})
)
// gets here after all promises are resolved
console.log(repoCount)
})()

Now because the requests are running in parallel, our code is much more efficient. The console.log will now print the correct result since it only runs after the last request finishes.

As a side note, be careful when writing loops with await. Because for..of is so easy to write, it can be tempting to run every array of tasks with it. Altough it is not technically wrong, you may be missing an opportunity to run the tasks in parallel and improve performance. There’s even an ESLint rule to disallow using await inside of loops.

5. Error handling

Unhnandled errors in Async/Await are swallowed “silently”.

This means you’ll want to either use a try/catch statement around your await declarations or a .catch at then end of it. See the example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(async () => {
try {
await getRubyDevelopers()
} catch (err){
// handle your error
handleError(err)
}
// or...
await getRubyDevelopers().catch(handleError)
})()

Like in Promises, unhandled errors propagate (bubble up).

We don’t have to wrap our code in a try/catch every time you use the await operator. In a scenario where a parent function calls others async functions, we can leverage the fact that errors bubble up to handle them in a single place.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function findNewJobs(){
try{
const devs = await getJQueryDevelopers()
const profiles = await getGitHubProfiles(devs)
await updateSkills(devs, ['React', 'Redux'])
await Promise.all([
addContributions(devs[0], profiles[0], ['redux-saga', 'relay']),
addContributions(devs[1], profiles[1], ['styled-components'])
])
await applyToReactJobs(devs, profiles, 'San Francisco')
} catch (err){
// catch-all error handler!
// all promise rejections inside
// of the try block will end up here
}
}

In the code above, any rejected promises returned by the functions within findNewJobs will be handled inside of the “catch-all” block.




By Ruan Martinelli