Understanding async/await in JavaScript

Published on March 16th, 2022 in Technology

The async and await keywords have been in JavaScript for some time now, with full support across all major browsers and Node. While a lot has been written on how to use them, they are still a mystery to many fresh JavaScript and Node developers.

This article aims to explain what async and await do and how they work, in simple terms, while not treating them as black magic.

To explain them, we have to go back to an earlier time, when all that JavaScript programmers had was a callback.

Callback hell

JavaScript is an asynchronous language and initially, all async work was handled via callbacks, like in this example:

setTimeout(function() {
    console.log("Hello after one second")
}, 1000)

The first argument to the setTimeout function is a callback, a function that will be called when setTimeout is done with its work.

While this works fine in simple cases, real-world code has many asynchronous steps and would look like this:

readFromDatabase(function (dbQueryResult) {
    ....
    callSomeExternalApi(function (apiResult) {
        ...
        saveToDatabase(function (dbSaveResult) {
            ...
        })
    })
})

This very quickly becomes very complex and very difficult to maintain.

Enter promises.

Promises

Promises were added to JavaScript as a solution to the callback hell. Instead of having to chain callbacks together in a recursive way, a Promise allows the programmer to chain the same callbacks in a much clearer way and are easier to maintain. Using promises, the same code would look something like this:

readFromDatabase()
    .then(function (dbQueryResult) {
        return callSomeExternalApi()
    })
    .then(function (apiResult) {
        return saveToDatabase()
    })
    .then(function (dbSaveResult) {
    })

This is much better - but wait, there's more.

A Promise is an object that can be returned, passed around, passed to other functions, put in an array, and so on. This allows for patterns that are much more advanced than just the callbacks we saw earlier.

For all their power, the actual code involving them is still a bit ugly. The then() chain is still somewhat verbose and creating a Promise can get messy.

To make it easier to work with promises, two new keywords were added: async and await.

Syntax sugar for a Promise

These two new keywords are "syntax sugar" for promises. That is, all they do is make it easier to write code that heavily relies on promises. That's all. There's nothing async and await give you that you couldn't do with a vanilla Promise. They're not magic. Here's what they do.

await

The await keyword makes it super-easy to wait for the Promise to get fulfilled. Using it, the promise chain from our earlier example can be rewritten as:

const dbQueryResult = await readFromDatabase()
const apiResult = await callSomeExternalApi()
const dbSaveResult = await saveToDatabase()

Many tutorials show this, say "see, magic! it turned asynchronous code into something that looks synchronous" and stop here. But here's how it looks when you peek under the hood.

First, let's refactor the earlier example to explicitly show what's happening when you use a promise chain:

const dbQueryPromise = readFromDatabase();
const dbApiPromise = dbQueryPromise.then(function (dbQueryResult) {
    // this code will execute once the dbQueryPromise has been fulfilled
    return callSomeExternalApi()
})
const dbSavePromise = dbApiPromise.then(function (dbApiResult) {
    // this code will execute once the dbApiPromise has been fulfilled
    return saveToDatabase()
})
...

That's annoying to write but makes it clear that each of the functions returns a Promise. We then wait for that promise to get fulfilled (using then()). Each of the callbacks contains chunks of the code that needs to be executed after the previous asynchronous operation has been completed.

With await, this looks like this:

const dbQueryPromise = readFromDatabase()
const dbQueryResult = await dbQueryPromise

const dbApiPromise = callSomeExternalApi()
const dbApiResult = await dbApiPromise

const dbSavePromise = await saveToDatabase()
const dbSaveResult = await dbSavePromise

Note that in this example, the dbQueryPromise is the same promise as in the previous example - likewise for dbApiPromise and dbSavePromise. It's just much easier to work with it using async.

async

As we've seen, under the hood await does the same promise chaining we were doing manually with then(). But if you use it in a function, what does that function return?

function foo() {
    // this won't work - can you guess why?
    const dbResult = await readFromDatabase()
    // ... do something with dbResult to calculate fooResult
    return fooResult
}

We can't just call foo() from somewhere and expect it to give us the answer immediately - it's not ready yet! So await automatically creates a promise chain out of your code. To properly return from it, you need to return a promise - and this is exactly what async does. It automatically wraps the entire function in a promise:

async function foo() {
    const dbResult = await readFromDatabase()
    // ... do something with dbResult to calculate fooResult
    return fooResult
}

That's all async does. It wraps the function in a Promise. An async function always returns a promise.

Manually creating and returning a promise without async would look something like this:

function foo() {
    return new Promise(function (resolve) {
        readFromDatabase()
        .then(function (dbResult) {
              // do something with dbResult to calculate fooResult ...
             resolve(dbResult)
        })
    })
}

To recap, always keep in mind is that if you use await in a function, you must define that function with async.

async doesn't always need await

You can freely use async without await, though! It's not as widely used, but is helpful when you just need to wrap a value in a Promise. Instead of:

function foo() {
    return new Promise(function (resolve) {
        resolve("hello")
    })
}

you can just write:

async function foo() { return "hello" }

Remember we said that async functions always return a Promise? This is true even if your code doesn't return anything!

async function foo() {}
...
const fooPromise = foo()
const fooResult = await fooPromise

Here fooPromise is a real Promise. Since the code in foo doesn't return anything, the result of resolving this promise (via await) is undefined.

Why is this useful? Because of errors.

Catching rejected promises

So far we've only discussed what happens if everything works fine. But if a promise fails, it calls catch() to catch the failure (rejection). In async functions, we can handle that nicely with a regular try/catch block, since promise rejections are turned into ordinary JavaScript exceptions. This means these two snippets of code are equivalent:

doSomething()
.then(function (result) {
    return doSomethingElse()
})
.catch(function (error) {
    handleError()
})

is the same as (in async function):

try {
    const result = await doSomething()
    doSomethingElse()
} catch (error) {
    handleError()
}

And here's why it's useful to have the async function always return a Promise even if you never return anything: so you can still wait for it to complete, or catch any errors that may happen.

Mixing normal and async functions

If just went quickly through the first tutorial on async/await you found, you might be pretty confused about what exactly happens when you start mixing normal (synchronous) code with async functions. But if you've carefully read so far, it should be straightforward.

Remember that async and await are not magic, they are just a nice way of using Promise. This means you can freely call async functions from non-async functions, and you can call non-async functions from async functions.

For example:

async function c() {
    return 1;
}

function b() {
    const cPromise = c();
}

Here, the non-async function b calls another function that returns a Promise. Note that b doesn't care if c is "async" or a normal function that manually returns a Promise. If c was defined as:

function c() {
    return new Promise(function (resolve, reject) {
        resolve(1);
    });
}

From the point of view of b, this c behaves the same way as the above async version. It's just that the async version is much nicer to write.

If you have a chain of functions that call each other a → b → c and c() is async, do all above it need to be async? No, but they'll need to wait on the promise (manually, via then() and catch()) if they want to use a result.

Sometimes you see a pattern like:

async function c() {
  ...
}

function b() { // this one is non-async
    const c_promise = c();
    return c_promise; // note it doesn't do anything with c_promise
}

async function a() {
    const c_result = await b();
}

This looks confusing until you realize that async and await just wrap and unwrap a normal Promise.

What happens here is that b doesn't care about the result of invoking c(), but knows a will care so returns the promise. a then awaits on the promise returned by b (which is the same one originally returned by c.


Hopefully, you now have a clearer understanding of how async and await work in JavaScript. To get an intuitive understanding, it's best to experiment: Node shell or your browser's JavaScript console are great for this. Just open it up and try to come up with different scenarios and see what happens. If the behavior seems strange, refer back to this article (and countless other articles and tutorial videos) and try to figure out why that will happen.

If you are already well-versed in details of Promises and async/await in JavaScript, this article may seem too shallow, too simplistic, or glossing over some interesting details. That's true. This tutorial is geared towards fresh programmers wanting to deepen their understanding of how this works, and making the first step towards that goal.


Read next: Background tasks in Node with Bull and Redis | Command-line shell for Express projects


About API Bakery

API Bakery generates boilerplate code for your backend service in seconds. Out of the box you get routes, data models, authentication, validation, tests, and integrations - customized for you and ready for production.

Try it now for free No sign up required