A Comparison Of <code>async/await</code> Versus <code>then/catch</code>

A Comparison Of async/await Versus then/catch

JavaScript runs code line by line, moving to the next line of code only after the previous one has been executed. But executing code like this can only take us so far. Sometimes, we need to perform tasks that take a long or unpredictable amount of time to complete: fetching data or triggering side-effects via an API, for example.

Rather than letting these tasks block JavaScript’s main thread, the language allows us to run certain tasks in parallel. ES6 saw the introduction of the Promise object as well as new methods to handle the execution of these Promises: then, catch, and finally. But a year later, in ES7, the language added another approach and two new keywords: async and await.

This article isn’t an explainer of asynchronous JavaScript; there are lots of good resources available for that. Instead, it addresses a less-covered topic: which syntax — then/catch or async/await — is better? In my view, unless a library or legacy codebase forces you to use then/catch, the better choice for readability and maintainability is async/await. To demonstrate that, we’ll use both syntaxes to solve the same problem. By slightly changing the requirements, it should become clear which approach is easier to tweak and maintain.

We’ll start by recapping the main features of each syntax, before moving to our example scenario.

then, catch And finally

then and catch and finally are methods of the Promise object, and they are chained one after the other. Each takes a callback function as its argument and returns a Promise.

For example, let’s instantiate a simple Promise:

const greeting = new Promise((resolve, reject) => {
  resolve("Hello!");
});

Using then, catch and finally, we could perform a series of actions based on whether the Promise is resolved (then) or rejected (catch) — while finally allows us to execute code once the Promise is settled, regardless of whether it was resolved or rejected:

greeting
  .then((value) => {
    console.log("The Promise is resolved!", value);
  })
  .catch((error) => {
    console.error("The Promise is rejected!", error);
  })
  .finally(() => {
    console.log(
      "The Promise is settled, meaning it has been resolved or rejected."
    );
  });

For the purposes of this article, we only need to use then. Chaining multiple then methods allows us to perform successive operations on a resolved Promise. For example, a typical pattern for fetching data with then might look something like this:

fetch(url)
  .then((response) => response.json())
  .then((data) => {
    return {
      data: data,
      status: response.status,
    };
  })
  .then((res) => {
    console.log(res.data, res.status);
  });

async And await

By contrast, async and await are keywords which make synchronous-looking code asynchronous. We use async when defining a function to signify that it returns a Promise. Notice how the placement of the async keyword depends on whether we’re using regular functions or arrow functions:

async function doSomethingAsynchronous() {
  // logic
}

const doSomethingAsynchronous = async () => {
  // logic
};

await, meanwhile, is used before a Promise. It pauses the execution of an asynchronous function until the Promise is resolved. For example, to await our greeting above, we could write:

async function doSomethingAsynchronous() {
  const value = await greeting;
}

We can then use our value variable as if it were part of normal synchronous code.

As for error handling, we can wrap any asynchronous code inside a try...catch...finally statement, like so:

async function doSomethingAsynchronous() {
  try {
    const value = await greeting;
    console.log("The Promise is resolved!", value);
  } catch (e) {
    console.error("The Promise is rejected!", error);
  } finally {
    console.log(
      "The Promise is settled, meaning it has been resolved or rejected."
    );
  }
}

Finally, when returning a Promise inside an async function, you don’t need to use await. So the following is acceptable syntax.

async function getGreeting() {
  return greeting;
}

However, there’s one exception to this rule: you do need to write return await if you’re looking to handle the Promise being rejected in a try...catch block.

async function getGreeting() {
  try {
    return await greeting;
  } catch (e) {
    console.error(e);
  }
}

Using abstract examples might help us understand each syntax, but it’s difficult to see why one might be preferable to the other until we jump into an example.

The Problem

Let’s imagine we need to perform an operation on a large dataset for a bookstore. Our task is to find all authors who have written more than 10 books in our dataset and return their bio. We have access to a library with three asynchronous methods:

// getAuthors - returns all the authors in the database
// getBooks - returns all the books in the database
// getBio - returns the bio of a specific author

Our objects look like this:

// Author: { id: "3b4ab205", name: "Frank Herbert Jr.", bioId: "1138089a" }
// Book: { id: "e31f7b5e", title: "Dune", authorId: "3b4ab205" }
// Bio: { id: "1138089a", description: "Franklin Herbert Jr. was an American science-fiction author..." }

Lastly, we’ll need a helper function, filterProlificAuthors, which takes all the posts and all the books as arguments, and returns the IDs of those authors with more than 10 books:

function filterProlificAuthors() {
  return authors.filter(
    ({ id }) => books.filter(({ authorId }) => authorId === id).length > 10
  );
}

The Solution

Part 1

To solve this problem, we need to fetch all the authors and all the books, filter our results based on our given criteria, and then get the bio of any authors who fit that criteria. In pseudo-code, our solution might look something like this:

FETCH all authors
FETCH all books
FILTER authors with more than 10 books
FOR each filtered author
  FETCH the author’s bio

Every time we see FETCH above, we need to perform an asynchronous task. So how could we turn this into JavaScript? First, let’s see how we might code these steps using then:

getAuthors().then((authors) =>
  getBooks()
    .then((books) => {
      const prolificAuthorIds = filterProlificAuthors(authors, books);
      return Promise.all(prolificAuthorIds.map((id) => getBio(id)));
    })
    .then((bios) => {
      // Do something with the bios
    })
);

This code does the job, but there’s some nesting going on that can make it difficult to understand at a glance. The second then is nested inside the first then, while the third then is parallel to the second.

Our code might become a little more readable if we used then to return even synchronous code? We could give filterProlificAuthors its own then method, like below:

getAuthors().then((authors) =>
  getBooks()
    .then((books) => filterProlificAuthors(authors, books))
    .then((ids) => Promise.all(ids.map((id) => getBio(id))))
    .then((bios) => {
      // Do something with the bios
    })
);

This version has the benefit that each then method fits on one line, but it doesn’t save us from multiple levels of nesting.

What about using async and await? Our first pass at a solution might look something like this:

async function getBios() {
  const authors = await getAuthors();
  const books = await getBooks();
  const prolificAuthorIds = filterProlificAuthors(authors, books);
  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));
  // Do something with the bios
}

To me, this solution already appears simpler. It involves no nesting and can be easily expressed in just four lines — all at the same level of indentation. However, the benefits of async/await will become more apparent as our requirements change.

Part 2

Let’s introduce a new requirement. This time, once we have our bios array, we want to create an object containing bios, the total number of authors, and the total number of books.

This time, we’ll start with async/await:

async function getBios() {
  const authors = await getAuthors();
  const books = await getBooks();
  const prolificAuthorIds = filterProlificAuthors(authors, books);
  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));
  const result = {
    bios,
    totalAuthors: authors.length,
    totalBooks: books.length,
  };
}

Easy! We don’t have to do anything to our existing code, since all the variables we need are already in scope. We can just define our result object at the end.

With then, it’s not so simple. In our then solution from Part 1, the books and bios variables are never in the same scope. While we could introduce a global books variable, that would pollute the global namespace with something we only need in our asynchronous code. It would be better to reformat our code. So how could we do it?

One option would be to introduce a third level of nesting:

getAuthors().then((authors) =>
  getBooks().then((books) => {
    const prolificAuthorIds = filterProlificAuthors(authors, books);
    return Promise.all(prolificAuthorIds.map((id) => getBio(id))).then(
      (bios) => {
        const result = {
          bios,
          totalAuthors: authors.length,
          totalBooks: books.length,
        };
      }
    );
  })
);

Alternatively, we could use array destructuring syntax to help pass books down through the chain at every step:

getAuthors().then((authors) =>
  getBooks()
    .then((books) => [books, filterProlificAuthors(authors, books)])
    .then(([books, ids]) =>
      Promise.all([books, ...ids.map((id) => getBio(id))])
    )
    .then(([books, bios]) => {
      const result = {
        bios,
        totalAuthors: authors.length,
        totalBooks: books.length,
      };
    })
);

To me, neither of these solutions is particularly readable. It’s difficult to work out — at a glance — which variables are accessible where.

Part 3

As a final optimisation, we can improve the performance of our solution and clean it up a little by using Promise.all to fetch the authors and books at the same time. This helps clean up our then solution a little:

Promise.all([getAuthors(), getBooks()]).then(([authors, books]) => {
  const prolificAuthorIds = filterProlificAuthors(authors, books);
  return Promise.all(prolificAuthorIds.map((id) => getBio(id))).then((bios) => {
    const result = {
      bios,
      totalAuthors: authors.length,
      totalBooks: books.length,
    };
  });
});

This may be the best then solution of the bunch. It removes the need for multiple levels of nesting and the code runs faster.

Nevertheless, async/await remains simpler:

async function getBios() {
  const [authors, books] = await Promise.all([getAuthors(), getBooks()]);
  const prolificAuthorIds = filterProlificAuthors(authors, books);
  const bios = await Promise.all(prolificAuthorIds.map((id) => getBio(id)));
  const result = {
    bios,
    totalAuthors: authors.length,
    totalBooks: books.length,
  };
}

There’s no nesting, only one level of indentation, and much less chance of bracket-based confusion!

Conclusion

Often, using chained then methods can require fiddly alterations, especially when we want to ensure certain variables are in scope. Even for a simple scenario like the one we discussed, there was no obvious best solution: each of the five solutions using then had different tradeoffs for readability. By contrast, async/await lent itself to a more readable solution that needed to change very little when the requirements of our problem were tweaked.

In real applications, the requirements of our asynchronous code will often be more complex than the scenario presented here. While async/await provides us with an easy-to-understand foundation for writing trickier logic, adding many then methods can easily force us further down the path towards callback hell — with many brackets and levels of indentation making it unclear where one block ends and the next begins.

For that reason — if you have the choice — choose async/await over then/catch.