Published on

JavaScript Async/Await - Stop Writing Callback Hell

Authors

Introduction

async/await transformed how JavaScript handles asynchronous code. Gone are the days of callback hell and messy promise chains. With async/await, asynchronous code reads like synchronous code — clean, readable, and easy to debug.

This guide takes you from the basics all the way to advanced real-world patterns.

The Problem: Callback Hell

Before async/await, async code looked like this:

// 😱 Callback Hell
getUser(userId, function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProduct(details.productId, function(product) {
        console.log(product)
        // And it keeps going...
      })
    })
  })
})

Nested, hard to read, impossible to maintain. Let's fix this.

Step 1: Promises

Promises made async code chainable:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProduct(details.productId))
  .then(product => console.log(product))
  .catch(err => console.error(err))

Better! But still has issues — error handling is awkward, and you lose access to previous values in later .then() calls.

Step 2: Async/Await

Async/await makes async code read like synchronous code:

async function loadProductFromOrder(userId) {
  try {
    const user = await getUser(userId)
    const orders = await getOrders(user.id)
    const details = await getOrderDetails(orders[0].id)
    const product = await getProduct(details.productId)
    console.log(product)
    return product
  } catch (err) {
    console.error('Error:', err)
  }
}

Clean, readable, and all variables are in scope throughout!

The Rules of Async/Await

// 1. async functions always return a Promise
async function getNumber() {
  return 42
}
getNumber().then(n => console.log(n))  // 42

// 2. await can only be used inside async functions
async function fetchData() {
  const response = await fetch('https://api.example.com/data')
  const data = await response.json()
  return data
}

// 3. Top-level await (works in ES modules)
const data = await fetchData()

Error Handling with try/catch

async function getUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`)

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`)
    }

    const user = await response.json()
    return user
  } catch (err) {
    // Handles both network errors and thrown errors
    console.error('Failed to fetch user:', err.message)
    return null
  }
}

Running Multiple Requests in Parallel

The most common performance mistake — running async calls one at a time when they could run together:

// ❌ Sequential — slow (5 + 3 + 4 = 12 seconds)
async function slow() {
  const users = await fetchUsers()    // 5s
  const posts = await fetchPosts()    // 3s
  const comments = await fetchComments()  // 4s
  return { users, posts, comments }
}

// ✅ Parallel — fast (max(5, 3, 4) = 5 seconds)
async function fast() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchComments(),
  ])
  return { users, posts, comments }
}

Use Promise.all() when requests don't depend on each other!

Promise.allSettled() — Don't Fail on One Error

const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/products'),
  fetch('/broken-endpoint'),  // This will fail
])

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('Success:', result.value)
  } else {
    console.log('Failed:', result.reason)
  }
})
// All 3 finish — no single failure breaks the others

Promise.race() and Promise.any()

// Promise.race() — resolves/rejects with the FIRST to settle
const fastest = await Promise.race([
  fetch('/api/server1/data'),
  fetch('/api/server2/data'),
])

// Promise.any() — resolves with the FIRST success (ES2021+)
const firstSuccess = await Promise.any([
  fetch('/api/server1/data'),  // might fail
  fetch('/api/server2/data'),  // might fail
  fetch('/api/server3/data'),  // this succeeds
])
// Returns server3's data even if others failed

Async Iteration

// Async iterators — iterate over async data sources
async function processStream() {
  const response = await fetch('/api/large-dataset')
  const reader = response.body.getReader()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    console.log('Chunk:', value)
  }
}

// for-await-of with async generators
async function* generateNumbers() {
  for (let i = 0; i < 5; i++) {
    await new Promise(resolve => setTimeout(resolve, 100))
    yield i
  }
}

async function main() {
  for await (const num of generateNumbers()) {
    console.log(num)  // 0, 1, 2, 3, 4 with 100ms delay each
  }
}

Real-World Pattern: Retry Logic

async function fetchWithRetry(url, retries = 3, delay = 1000) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    } catch (err) {
      if (attempt === retries) throw err
      console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`)
      await new Promise(resolve => setTimeout(resolve, delay))
      delay *= 2  // Exponential backoff
    }
  }
}

const data = await fetchWithRetry('https://api.example.com/data')

Conclusion

Async/await is one of the most important features in modern JavaScript. It eliminates callback hell, makes error handling intuitive, and lets you write asynchronous code that's as readable as synchronous code. Combined with Promise.all(), Promise.allSettled(), and retry patterns, you have everything you need to handle any async scenario.