In this article, we will explore the modern approach to handling asynchronous functions in JavaScript using async/await syntax. JavaScript has evolved rapidly, transitioning from callbacks to promises (ES2015), and now providing even simpler asynchronous JavaScript with the introduction of async/await in ES2017.

Introduction

Async functions in JavaScript combine the features of promises and generators, serving as a higher-level abstraction over promises. It is important to note that async/await is built on promises.

Why were async/await introduced?

Async/await was introduced to reduce the boilerplate code associated with promises, as well as to address the limitations of chaining promises known as the “don’t break the chain” problem.

While promises were introduced in ES2015 to solve the issue of “callback hell”, over the course of the following two years, it became clear that promises alone were not the optimal solution. Although promises provided a much-needed improvement, they introduced their own complexity and syntax intricacies. Thus, async functions were introduced to serve as a more developer-friendly syntax for handling asynchronous code.

Async/await syntax makes asynchronous code appear synchronous and non-blocking, enhancing code readability and maintainability.

How it works

An async function returns a promise. For example:

const doSomethingAsync = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 3000);
  });
};

To call this function, prepend await, and the calling code will pause until the promise is resolved or rejected. However, the client function must be defined with the async keyword. Here’s an example:

const doSomething = async () => {
  console.log(await doSomethingAsync());
};

A quick example

Let’s take a simple example of async/await in action:

const doSomethingAsync = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 3000);
  });
};

const doSomething = async () => {
  console.log(await doSomethingAsync());
};

console.log('Before');
doSomething();
console.log('After');

The above code will output the following to the browser console:

Before
After
I did something // after 3s

Promise all the things

By prefixing the async keyword to any function, the function will return a promise. Even if the function does not explicitly use a return statement, it will internally make it return a promise. This allows for shorter and more readable code. For example:

const aFunction = async () => {
  return 'test';
};

aFunction().then(alert); // This will alert 'test'

The above code is equivalent to:

const aFunction = async () => {
  return Promise.resolve('test');
};

aFunction().then(alert); // This will alert 'test'

The code is much simpler to read

As the examples above illustrate, code written using async/await syntax is significantly more readable and simpler compared to code using plain promises with chained callbacks. This advantage becomes even more pronounced with complex code.

For instance, consider fetching a JSON resource and parsing it using promises:

const getFirstUserData = () => {
  return fetch('/users.json') // get users list
    .then(response => response.json()) // parse JSON
    .then(users => users[0]) // pick first user
    .then(user => fetch(`/users/${user.name}`)) // get user data
    .then(userResponse => userResponse.json()); // parse JSON
};

getFirstUserData();

The same functionality can be achieved with much cleaner code using async/await:

const getFirstUserData = async () => {
  const response = await fetch('/users.json'); // get users list
  const users = await response.json(); // parse JSON
  const user = users[0]; // pick first user
  const userResponse = await fetch(`/users/${user.name}`); // get user data
  const userData = await userResponse.json(); // parse JSON
  return userData;
};

getFirstUserData();

Multiple async functions in series

Chaining async functions is straightforward and much more readable than chaining promises with plain syntax:

const promiseToDoSomething = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000);
  });
};

const watchOverSomeoneDoingSomething = async () => {
  const something = await promiseToDoSomething();
  return something + ' and I watched';
};

const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
  const something = await watchOverSomeoneDoingSomething();
  return something + ' and I watched as well';
};

watchOverSomeoneWatchingSomeoneDoingSomething().then(res => {
  console.log(res);
});

The output will be:

I did something and I watched and I watched as well

Easier debugging

Debugging promises can be challenging, as the debugger does not step over asynchronous code. However, async/await makes debugging much easier since the code appears synchronous to the compiler.