Choosing Between JavaScript’s Two Async Syntaxes

JavaScript offers two primary ways to work with asynchronous code: the Promise methods then/catch introduced in ES6, and the async/await keywords added in ES7. Both provide the same core functionality — handling operations like API calls that would otherwise block the main thread — but they differ significantly in how they shape code structure and readability.

This article compares the two approaches by solving the same problem with each syntax and then modifying the requirements. The goal is to show which style is easier to maintain as complexity grows. Unless a codebase or library mandates then/catch, async/await generally produces cleaner, more adaptable code.

Understanding the Fundamentals

then, catch, and finally are methods on the Promise object. They are chained sequentially, each taking a callback and returning a new Promise. A basic Promise instance can be handled like this:

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

Chaining these methods allows you to act on a resolved Promise (then), handle a rejection (catch), and run cleanup code once the Promise settles (finally):

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."
    );
  });

A typical data-fetching pattern using chained then methods looks 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, make asynchronous code read like synchronous code. The async keyword marks a function as returning a Promise, with placement varying between regular and arrow functions:

async function doSomethingAsynchronous() {
  // logic
}

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

Inside an async function, await pauses execution until a Promise resolves. For the greeting Promise, this means:

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

The resolved value then behaves like a normal variable. Error handling uses standard try...catch...finally blocks around the asynchronous operations:

async function doSomethingAsynchronous() {
  try {
    const value = await greeting;
    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."
    );
  }
}

A notable nuance: you generally don’t need await when returning a Promise from an async function. This is valid syntax:

async function getGreeting() {
  return greeting;
}

However, return await is required when you need to catch rejections within a try...catch block:

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

The Practical Test: A Bookstore Data Problem

To see how these syntaxes hold up, consider a scenario where you need to find authors who have written more than 10 books and return their bios. A library provides 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

The data objects have this structure:

// 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..." }

A helper function, filterProlificAuthors, takes all posts and books as arguments and returns the IDs of qualifying authors:

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

Initial Implementation

The pseudo-code solution involves fetching all authors and books, filtering, and then fetching the bios of those who meet the criteria:

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

With then, the implementation introduces nesting that obscures the flow:

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
    })
);

You can make some improvements by assigning synchronous operations their own then chained onto the fetch result, which keeps each line shorter but doesn’t eliminate the nested structure:

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
    })
);

Using async/await for the same task produces a flat, four-line sequence with no nesting and consistent indentation:

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
}

Even at this basic level, the difference in clarity is evident, but it becomes more pronounced when requirements change.

Adding Requirements: Scope Complications

Now assume you need to return not just the bios but also an object containing the total number of authors and books. With async/await, this is a trivial addition since the required variables remain in scope throughout the function:

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,
  };
}

With then, the issue is that books and bios never share the same scope in the earlier solution. You could introduce a global variable, but that pollutes the namespace. A better approach might require deeper nesting to bring the values together:

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, you can thread the books data through the chain using array destructuring at each 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,
      };
    })
);

Neither of these then-based approaches reads clearly; it’s hard to track which variables are accessible at any given point. The async/await version required no restructuring at all.

Performance Optimization with Promise.all

The final variant improves performance by fetching authors and books concurrently via Promise.all. This simplifies the then chain considerably:

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,
    };
  });
});

Still, the async/await version remains more direct, with no nesting, a single indentation level, and fewer brackets to mismanage:

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,
  };
}

Why Readability Matters for Maintenance

The exercise shows that chained then methods require careful scoping decisions that vary from one solution to the next. Each of the five then-based implementations held different tradeoffs, and none of them made the logical flow obvious at a glance.

In real applications, asynchronous code is often more complex than this example. async/await gives you a straightforward foundation for adding logic, while multiple then methods introduce indentation, brackets, and unclear scope boundaries — the classic signs of moving toward callback hell. If your project allows the choice, async/await is the stronger option for code that will be read, edited, and debugged over time.

Further Reading

Smashing Editorial