Why We Moved from Promises to Async/Await in Node.js

By Guilherme Luiz Maia Pinto
Picture of the author
Published on
Async/Await vs Promises in Node.js Banner

Introduction

In the ever-evolving world of JavaScript and Node.js, developers seek tools and techniques to write cleaner, more maintainable code. One of the biggest shifts has been moving from Promises to async/await. Why did this change happen? Let’s explore the reasons and how async/await transformed the way we handle asynchronous code.


The Challenges of Promises

Promises were a major improvement over “callback hell.” They brought structure and made chaining tasks simpler. Still, Promises introduce a few challenges:

  1. Nested chains and readability
getUserData()
  .then((user) => {
    return getUserPosts(user.id)
  })
  .then((posts) => {
    return getPostComments(posts[0].id)
  })
  .then((comments) => {
    console.log(comments)
  })
  .catch((err) => {
    console.error(err)
  })

This is better than deeply nested callbacks, but long .then() chains can become hard to read when there are multiple dependencies.

  1. Error handling complexity

Promises require careful placement of .catch() blocks. Missing one can lead to unhandled rejections that are hard to debug.

  1. Debugging and stack traces

Asynchronous boundaries can make traces harder to follow, complicating root-cause analysis.


Enter Async/Await

Introduced in ES2017, async/await is syntax built on top of Promises. It lets you write asynchronous code that looks synchronous.

1) Simplified syntax and readability

async function fetchUserData() {
  try {
    const user = await getUserData()
    const posts = await getUserPosts(user.id)
    const comments = await getPostComments(posts[0].id)
    console.log(comments)
  } catch (err) {
    console.error(err)
  }
}

This reads top-to-bottom, making the flow and dependencies clear.

2) Better error handling

Use standard try/catch—familiar and consistent:

async function fetchData() {
  try {
    const data = await getData()
    console.log(data)
  } catch (err) {
    console.error('Error fetching data:', err)
  }
}

3) Debugging made easier

Stack traces are typically easier to follow because the code looks and steps like synchronous logic.

4) Composability and modularity

Break logic into smaller reusable functions:

async function getUserWithComments(userId) {
  const user = await getUserData(userId)
  const posts = await getUserPosts(user.id)
  const comments = await getPostComments(posts[0].id)
  return comments
}

async function displayComments() {
  try {
    const comments = await getUserWithComments(1)
    console.log(comments)
  } catch (err) {
    console.error(err)
  }
}

The Shift in the Node.js Ecosystem

Async/await required modern runtimes, so older Node.js versions didn’t support it initially. With Node.js 8+ and widespread adoption, async/await became standard. Frameworks like Express, Koa, and Fastify support async handlers, and many libraries updated their APIs accordingly.


Are Promises Obsolete?

No. Async/await is built on Promises—understanding Promises is still essential. In some cases, Promises are the better tool:

  • Parallel execution with Promise.all():
async function loadUserAndPosts() {
  const [user, posts] = await Promise.all([getUserData(), getUserPosts()])
  return { user, posts }
}
  • Low-level APIs: Some libraries expose Promise-based functions directly—you’ll interact with them as-is.

Conclusion

The transition from Promises to async/await represents a move toward code that is more readable, maintainable, and easier to debug. Promises laid the foundation; async/await took it to the next level. If you haven’t embraced async/await in your Node.js projects, now is a great time—it simplifies code and helps you work more effectively.

Stay Tuned

Want to become a Software Engineer pro?
The best articles and links related to web development are delivered once a week to your inbox.