Skip to content

3 Reasons Why Async/Await Is Better Than Chaining Promises

5 min read

If you're already familiar with promises and chaining them you might be wondering โ€” why learn a new syntax? Why use async/await when you can use promises to accomplish the same thing? ๐Ÿคจ

And yet developers love async/await and are saying how it has helped them write better code. Makes you wonder if they know something you don't...

Only if you had someone to show you where promises fall short. It could give you the insight you've been missing all this time! ๐Ÿ’ก

Well, you do!

Grab a seat and read on as I point out the shortcomings of Promise.then() syntax and how async/await helps you write better asynchronous JavaScript code. ๐ŸŽฏ

Reason #1 - Confusing order of execution

One of the first things we're taught in programming is that code is executed from top to bottom. We tell machines what to do by writing code as a sequence of chronological steps โ€” first do this, then do that, and finally run that.

By definition, asynchronous code doesn't run linearly and it contradicts how we've learned to read and reason about code.

While promises are a vast improvement over callbacks (and they power async/await under the hood), they can give you the impression that code runs in a weird order.

Here is an example with print statements before, inside, and after the promise chain:

console.log('Start'); // 1
insertComment(comment)
  .then((commentId) => {
    console.log('Comment ID is', commentId); // 3
  });

console.log('End'); // 2

This prints the following:

Start
End
Comment ID is e03c2055-2309-494f-b37e-f153bc673445

This is unintuitive because it looks like the program is jumping from end to middle, when in fact code always executes from top to bottom. What happens is that the execution of asynchronous code is deferred to a future point in time with the help of the event loop.

When we take the same code and refactor it to async/await, it not only runs from top to bottom but also reads from top to bottom.

console.log('Start'); // 1
const commentId = await insertComment(comment);
console.log('Comment ID is', commentId); // 2
console.log('End'); // 3

Which unsurprisingly prints:

Start
Comment ID is e03c2055-2309-494f-b37e-f153bc673445
End

Async/await allows you to write asynchronous code that reads like synchronous code.

And that's powerful.

Reason #2 - Reusing values inside promise chains

Another issue with promise chains is when you want to reuse values at later steps in the chain. Each .then() method creates a separate function scope and, therefore, prevents accessing its variables from later steps in the chain.

insertComment(comment)
  .then((commentId) => {
    return insertHashtag(hashtag, commentId);
  })
  .then((hashtagId) => {
    // how do we use commentId in here?
    console.log('Comment ID is', commentId);
  });

In practice, this issue is often worse because you have longer promise chains and you want to access several values at different steps in the chain.

Promise.then() solutions and their shortcomings

You could solve this in a few different ways with Promise.then() syntax, but let me show you why they're not ideal.

A solution would be to initialize the variables you need outside of the promise chain. This allows you to reference them at later steps in the chain because the variables are declared in a shared outer scope.

// Initialize variable in outer scope so we can reference it
// from a later step in the promise chain
let commentId;
insertComment(comment)
  .then((newCommentId) => {
    // Need to come up with a new name to avoid name clash ๐Ÿ™…โ€โ™‚๏ธ
    commentId = newCommentId;
    return insertHashtag(hashtag, commentId);
  })
  .then((hashtagId) => {
    console.log('Comment ID is', commentId);
  });

The problem with this is that you have to come up with different names for each variable to avoid name clashes, and we all know that naming things is hard in programming.

Moreover, with longer promise chains it's hard to keep track of where the variables were assigned and what values they hold. I've had to debug some long promise chains in production that were using this solution and I can tell you it was not fun.

Another solution is to add a nested promise chain and resolve with the value needed in the next step.

insertComment(comment)
  .then((commentId) => {
    return insertHashtag(hashtag, commentId)
      // Add a nested promise chain and resolve with the value
      // needed in the next step
      .then((hashtagId) => {
        return commentId; // this nesting though ๐Ÿ˜ต
      });
  })
  .then((commentId) => {
    console.log('Comment ID is', commentId);
  });

However, nesting promise chains goes against why you would chain promises in the first place โ€” which is to create a flat async structure. Everyone hated callback hell (a.k.a. pyramid of doom) from earlier days of asynchronous JavaScript, and nobody wants to work with that mess again.

You also need to make sure to return nested promises otherwise rejections won't bubble up and your Node.js server will quit unexpectedly.

Lastly, you could use Promise.all to pass an array of values to subsequent steps in the chain.

insertComment(comment)
  .then((commentId) => {
    // Need to make sure to keep this array in sync with..
    return Promise.all([
      commentId,
      insertHashtag(hashtag, commentId)
    ]);
  })
  .then(([commentId, hashtagId]) => { // <-- ..this array
    console.log('Comment ID is', commentId);
  });

This is in my opinion the "least worse" solution, as it avoids name clashes or nesting promises. Before async/await was a thing, this would be my go-to approach.

The downside is that you have to keep both arrays in sync. When you change something in one array it's easy to forget about the other. Adding Promise.all in your promise chains quickly becomes tedious and it affects the readability of your code.

Using async/await

Let's look at how you'd write this with async/await.

// โœจ
const commentId = await insertComment(comment);
await insertHashtag(hashtag, commentId);
console.log('Comment ID is', commentId);

There's not much to explain really, it's concise and clear. No separate function scopes to worry about and no weird workarounds. Amazing!

Reason #3 - Conditional asynchronous tasks

Another reason to use async/await over Promise.then() syntax is when you have async tasks inside an if statement. It's tricky to handle this nicely with Promise.then() and you'll soon find yourself in a "promise hell".

insertComment(comment)
  .then((commentId) => {
    if (hashtag) {
      // Ugh this nesting starts to look like callback hell...
      return insertHashtag(hashtag, commentId)
        .then((hashtagId) => {
          // If we don't resolve with commentId the next value
          // in the chain might be hashtagId and we'll have
          // to add a check for that
          return commentId;
        });
    }

    return commentId;
  })
  .then((commentId) => {
    console.log('Comment ID is', commentId);
  });

You can't get around the nesting problem because if you resolve with a different value (hashtagId) you won't know what value you're getting in the next step (hashtagId or commentId?). In some situations you can programmatically check which value you're working with, but often you can't.

With async/await this is a non-issue. You just put the entire logic inside an if statement and that's it.

// ๐Ÿš€
const commentId = await insertComment(comment);
if (hashtag) {
  await insertHashtag(hashtag, commentId);
}

console.log('Comment ID is', commentId);

This code is straightforward and runs as expected.

By now you're familiar with the scenarios where Promise.then() syntax falls short and how async/await empowers you to write clean asynchronous code. Use this knowledge to write better JavaScript code and progress in your development career.

Transform Callbacks into Clean Async Code! ๐Ÿš€

Tired of messy callback code? Download this FREE 5-step guide to master async/await and simplify your asynchronous code.

In just a few steps, you'll transform complex logic into readable, modern JavaScript that's easy to maintain. With clear visuals, each step breaks down the process so you can follow along effortlessly.

Refactoring Callbacks Guide Preview

You'll also get tips on building scalable Node.js applications about twice a month. I respect your email privacy. Unsubscribe any time.

You might also like

Why You Shouldn't Mix Promise.then() With Async/Await Syntax

Mixing Promise.then() with async/await syntax is a recipe for bugs. Here's why you should avoid it and what to do instead.
Read article

Avoid This Mistake When Caching Asynchronous Results

Caching is hard. Caching asynchronous results is even harder. Learn how to properly cache promise results in JavaScript.
Read article

Understanding Async & Await

Async/await can be intimidating. With a little guidance, you can write modern asynchronous JavaScript code that just works.
Read article