A Beginner’s Guide to JavaScript async/await, with Examples

The async and await keywords in JavaScript provide a modern syntax to help us handle asynchronous operations. In this tutorial, we’ll take an in-depth look at how to use async/await to master flow control in our JavaScript programs.

Contents:

  1. How to Create a JavaScript Async Function
  2. JavaScript Await/Async Uses Promises Under the Hood
  3. Error Handling in Async Functions
  4. Running Asynchronous Commands in Parallel
  5. Asynchronous Awaits in Synchronous Loops
  6. Top-level Await
  7. Write Asynchronous Code with Confidence

In JavaScript, some operations are asynchronous. This means that the result or value they produce isn’t immediately available.

Consider the following code:

function fetchDataFromApi() { console.log(data);
} fetchDataFromApi();
console.log('Finished fetching data');

The JavaScript interpreter won’t wait for the asynchronous fetchDataFromApi function to complete before moving on to the next statement. Consequently, it logs Finished fetching data before logging the actual data returned from the API.

In many cases, this isn’t the desired behavior. Luckily, we can use the async and await keywords to make our program wait for the asynchronous operation to complete before moving on.

This functionality was introduced to JavaScript in ES2017 and is supported in all modern browsers.

How to Create a JavaScript Async Function

Let’s take a closer look at the data fetching logic in our fetchDataFromApi function. Data fetching in JavaScript is a prime example of an asynchronous operation.

Using the Fetch API, we could do something like this:

function fetchDataFromApi() { fetch('https://v2.jokeapi.dev/joke/Programming?type=single') .then(res => res.json()) .then(json => console.log(json.joke));
} fetchDataFromApi();
console.log('Finished fetching data');

Here, we’re fetching a programming joke from the JokeAPI. The API’s response is in JSON format, so we extract that response once the request completes (using the json() method), then log the joke to the console.

Please note that the JokeAPI is a third-party API, so we can’t guarantee the quality of jokes that will be returned!

If we run this code in your browser, or in Node (version 17.5+ using the --experimental-fetch flag), we’ll see that things are still logged to the console in the wrong order.

Let’s change that.

The async keyword

The first thing we need to do is label the containing function as being asynchronous. We can do this by using the async keyword, which we place in front of the function keyword:

async function fetchDataFromApi() { fetch('https://v2.jokeapi.dev/joke/Programming?type=single') .then(res => res.json()) .then(json => console.log(json.joke));
}

Asynchronous functions always return a promise (more on that later), so it would already be possible to get the correct execution order by chaining a then() onto the function call:

fetchDataFromApi() .then(() => { console.log('Finished fetching data'); });

If we run the code now, we see something like this:

If Bill Gates had a dime for every time Windows crashed ... Oh wait, he does.
Finished fetching data

But we don’t want to do that! JavaScript’s promise syntax can get a little hairy, and this is where async/await shines: it enables us to write asynchronous code with a syntax which looks more like synchronous code and which is more readable.

The await keyword

The next thing to do is to put the await keyword in front of any asynchronous operations within our function. This will force the JavaScript interpreter to “pause” execution and wait for the result. We can assign the results of these operations to variables:

async function fetchDataFromApi() { const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single'); const json = await res.json(); console.log(json.joke);
}

We also need to wait for the result of calling the fetchDataFromApi function:

await fetchDataFromApi();
console.log('Finished fetching data');

Unfortunately, if we try to run the code now, we’ll encounter an error:

Uncaught SyntaxError: await is only valid in async functions, async generators and modules

This is because we can’t use await outside of an async function in a non-module script. We’ll get into this in more detail later, but for now the easiest way to solve the problem is by wrapping the calling code in a function of its own, which we’ll also mark as async:

async function fetchDataFromApi() { const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single'); const json = await res.json(); console.log(json.joke);
} async function init() { await fetchDataFromApi(); console.log('Finished fetching data');
} init();

If we run the code now, everything should output in the correct order:

UDP is better in the COVID era since it avoids unnecessary handshakes.
Finished fetching data

The fact that we need this extra boilerplate is unfortunate, but in my opinion the code is still easier to read than the promise-based version.

Different ways of declaring async functions

The previous example uses two named function declarations (the function keyword followed by the function name), but we aren’t limited to these. We can also mark function expressions, arrow functions and anonymous functions as being async.

If you’d like a refresher on the difference between function declarations and function expressions, check out our guide on when to use which.

Async function expression

A function expression is when we create a function and assign it to a variable. The function is anonymous, which means it doesn’t have a name. For example:

const fetchDataFromApi = async function() { const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single'); const json = await res.json(); console.log(json.joke);
}

This would work in exactly the same way as our previous code.

Async arrow function

Arrow functions were introduced to the language in ES6. They’re a compact alternative to function expressions and are always anonymous. Their basic syntax is as follows:

(params) => { <function body> }

To mark an arrow function as asynchronous, insert the async keyword before the opening parenthesis.

For example, an alternative to creating an additional init function in the code above would be to wrap the existing code in an IIFE, which we mark as async:

(async () => { async function fetchDataFromApi() { const res = await fetch('https://v2.jokeapi.dev/joke/Programming?type=single'); const json = await res.json(); console.log(json.joke); } await fetchDataFromApi(); console.log('Finished fetching data');
})();

There’s not a big difference between using function expressions or function declarations: mostly it’s just a matter of preference. But there are a couple of things to be aware of, such as hoisting, or the fact that an arrow function doesn’t bind its own this value. You can check the links above for more details.

JavaScript Await/Async Uses Promises Under the Hood

As you might have already guessed, async/await is, to a large extent, syntactic sugar for promises. Let’s look at this in a little more detail, as a better understanding of what’s happening under the hood will go a long way to understanding how async/await works.

If you’re not sure what promises are, or if you’d like a quick refresher, check out our promises guide.

The first thing to be aware of is that an async function will always return a promise, even if we don’t explicitly tell it to do so. For example:

async function echo(arg) { return arg;
} const res = echo(5);
console.log(res);

This logs the following:

Promise { <state>: "fulfilled", <value>: 5 }

A promise can be in one of three states: pending, fulfilled, or rejected. A promise starts life in a pending state. If the action relating to the promise is successful, the promise is said to be fulfilled. If the action is unsuccessful, the promise is said to be rejected. Once a promise is either fulfilled or rejected, but not pending, it’s also considered settled.

When we use the await keyword inside of an async function to “pause” function execution, what’s really happening is that we’re waiting for a promise (either explicit or implicit) to settle into a resolved or a rejected state.

Building on our above example, we can do the following:

async function echo(arg) { return arg;
} async function getValue() { const res = await echo(5); console.log(res);
} getValue(); 

Because the echo function returns a promise and the await keyword inside the getValue function waits for this promise to fulfill before continuing with the program, we’re able to log the desired value to the console.

Promises are a big improvement to flow control in JavaScript and are used by several of the newer browser APIs — such as the Battery status API, the Clipboard API, the Fetch API, the MediaDevices API, and so on.

Node has also added a promisify function to its built-in util module that converts code that uses callback functions to return promises. And as of v10, functions in Node’s fs module can return promises directly.

Switching from promises to async/await

So why does any of this matter to us?

Well, the good news is that any function that returns a promise can be used with async/await. I’m not saying that we should async/await all the things (this syntax does have its downsides, as we’ll see when we get on to error handling), but we should be aware that this is possible.

We’ve already seen how to alter our promise-based fetch call at the top of the article to work with async/await, so let’s have a look at another example. Here’s a small utility function to get the contents of a file using Node’s promise-based API and its readFile method.

Using Promise.then():

const { promises: fs } = require('fs'); const getFileContents = function(fileName) { return fs.readFile(fileName, enc)
} getFileContents('myFile.md', 'utf-8') .then((contents) => { console.log(contents); });

With async/await that becomes:

import { readFile } from 'node:fs/promises'; const getFileContents = function(fileName, enc) { return readFile(fileName, enc)
} const contents = await getFileContents('myFile.md', 'utf-8');
console.log(contents);

Note: this is making use of a feature called top-level await, which is only available within ES modules. To run this code, save the file as index.mjs and use a version of Node >= 14.8.

Although these are simple examples, I find the async/await syntax easier to follow. This becomes especially true when dealing with multiple then() statements and with error handling thrown in to the mix. I wouldn’t go as far as converting existing promise-based code to use async/await, but if that’s something you’re interested in, VS Code can do it for you.

Error Handling in Async Functions

There are a couple of ways to handle errors when dealing with async functions. Probably the most common is to use a try...catch block, which we can wrap around asynchronous operations and catch any errors which occur.

In the following example, note how I’ve altered the URL to something that doesn’t exist:

async function fetchDataFromApi() { try { const res = await fetch('https://non-existent-url.dev'); const json = await res.json(); console.log(json.joke); } catch (error) { console.log('Something went wrong!'); console.warn(error) }
} await fetchDataFromApi();
console.log('Finished fetching data');

This will result in the following message being logged to the console:

Something went wrong!
TypeError: fetch failed ... cause: Error: getaddrinfo ENOTFOUND non-existent-url.dev
Finished fetching data

This works because fetch returns a promise. When the fetch operation fails, the promise’s reject method is called and the await keyword converts that unhanded rejection to a catchable error.

However, there are a couple of problems with this method. The main criticism is that it’s verbose and rather ugly. Imagine we were building a CRUD app and we had a separate function for each of the CRUD methods (create, read, update, destroy). If each of these methods performed an asynchronous API call, we’d have to wrap each call in its own try...catch block. That’s quite a bit of extra code.

The other problem is that, if we haven’t used the await keyword, this results in an unhandled promise rejection:

import { readFile } from 'node:fs/promises'; const getFileContents = function(fileName, enc) { try { return readFile(fileName, enc) } catch (error) { console.log('Something went wrong!'); console.warn(error) }
} const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8');
console.log(contents);

The code above logs the following:

node:internal/process/esm_loader:91 internalBinding('errors').triggerUncaughtException( ^
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] { errno: -2, code: 'ENOENT', syscall: 'open', path: 'this-file-does-not-exist.md'
}

Unlike await, the return keyword doesn’t convert promise rejections to catchable errors.

Making Use of catch() on the function call

Every function that returns a promise can make use of a promise’s catch method to handle any promise rejections which might occur.

With this simple addition, the code in the above example will handle the error gracefully:

const contents = await getFileContents('this-file-does-not-exist.md', 'utf-8') .catch((error) => { console.log('Something went wrong!'); console.warn(error); });
console.log(contents);

And now this outputs the following:

Something went wrong!
[Error: ENOENT: no such file or directory, open 'this-file-does-not-exist.md'] { errno: -2, code: 'ENOENT', syscall: 'open', path: 'this-file-does-not-exist.md'
}
undefined

As to which strategy to use, I agree with the advice of Valeri Karpov. Use try/catch to recover from expected errors inside async functions, but handle unexpected errors by adding a catch() to the calling function.

Running Asynchronous Commands in Parallel

When we use the await keyword to wait for an asynchronous operation to complete, the JavaScript interpreter will accordingly pause execution. While this is handy, this might not always be what we want. Consider the following code:

(async () => { async function getStarCount(repo){ const repoData = await fetch(repo); const repoJson = await repoData.json() return repoJson.stargazers_count; } const reactStars = await getStarCount('https://api.github.com/repos/facebook/react'); const vueStars = await getStarCount('https://api.github.com/repos/vuejs/core'); console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`)
})();

Here we are making two API calls to get the number of GitHub stars for React and Vue respectively. While this works just fine, there’s no reason for us to wait for the first resolved promise before we make the second fetch request. This would be quite a bottleneck if we were making many requests.

To remedy this, we can reach for Promise.all, which takes an array of promises and waits for all promises to be resolved or for any one of them to be rejected:

(async () => { async function getStarCount(repo){ } const reactPromise = getStarCount('https://api.github.com/repos/facebook/react'); const vuePromise = getStarCount('https://api.github.com/repos/vuejs/core'); const [reactStars, vueStars] = await Promise.all([reactPromise, vuePromise]); console.log(`React has ${reactStars} stars, whereas Vue has ${vueStars} stars`);
})();

Much better!

Asynchronous Awaits in Synchronous Loops

At some point, we’ll try calling an asynchronous function inside a synchronous loop. For example:


const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); async function process(array) { array.forEach(async (el) => { await sleep(el); console.log(el); });
} const arr = [3000, 1000, 2000];
process(arr);

This won’t work as expected, as forEach will only invoke the function without waiting for it to complete and the following will be logged to the console:

1000
2000
3000

The same thing applies to many of the other array methods, such as map, filter and reduce.

Luckily, ES2018 introduced asynchronous iterators, which are just like regular iterators except their next() method returns a promise. This means we can use await within them. Let’s rewrite the above code using one of these new iterators — for…of:

async function process(array) { for (el of array) { await sleep(el); console.log(el); };
}

Now the process function outputs everything in the correct order:

3000
1000
2000

As with our previous example of awaiting asynchronous fetch requests, this will also come at a performance cost. Each await inside the for loop will block the event loop, and the code should usually be refactored to create all the promises at once, then get access to the results using Promise.all().

There is even an ESLint rule which complains if it detects this behavior.

Top-level Await

Finally, let’s look at something called top-level await. This is was introduced to the language in ES2022 and has been available in Node as of v14.8.

We’ve already been bitten by the problem that this aims to solve when we ran our code at the start of the article. Remember this error?

Uncaught SyntaxError: await is only valid in async functions, async generators and modules

This happens when we try to use await outside of an async function. For example, at the top level of our code:

const ms = await Promise.resolve('Hello, World!');
console.log(msg);

Top-level await solves this problem, making the above code valid, but only within an ES module. If we’re working in the browser, we could add this code to a file called index.js, then load it into our page like so:

<script src="index.js" type="module"></script>

And things will work as expected — with no need for a wrapper function or the ugly IIFE.

Things get more interesting in Node. To declare a file as an ES module, we should do one of two things. One option is to save with an .mjs extension and run it like so:

node index.mjs

The other option is to set "type": "module" in the package.json file:

{ "name": "myapp", "type": "module", ...
}

Top-level await also plays nicely with dynamic imports — a function-like expression that allows us to load an ES module asynchronously. This returns a promise, and that promise resolves into a module object, meaning we can do something like this:

const locale = 'DE'; const { default: greet } = await import( `${ locale === 'DE' ? './de.js' : './en.js' }`
); greet(); 

The dynamic imports option also lends itself well to lazy loading in combination with frameworks such as React and Vue. This enables us to reduce our initial bundle size and time to interactive metric.

Write Asynchronous Code with Confidence

In this article, we’ve looked at how you can manage the control flow of your JavaScript program using async/await. We’ve discussed the syntax, how async/await works under the hood, error handling, and a few gotchas. If you’ve made it this far, you’re now a pro. 🙂

Writing asynchronous code can be hard, especially for beginners, but now that you have a solid understanding of the techniques, you should be able to employ them to great effect.

Happy coding!

If you have any questions or comments, let me know on Twitter.