JavaScript Async/Await — Stop Writing Callback Hell

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Asynchronous programming is at the heart of JavaScript. Every API call, database query, file read, and timer is async. The evolution from callbacks to Promises to async/await made async code progressively more readable and maintainable. In 2026, async/await is the universal standard — used in React data fetching, Node.js API handlers, Next.js server components, and every modern JavaScript codebase.

Understanding async deeply also means understanding error propagation, concurrent execution with Promise.all, cancellation with AbortController, and how async functions interact with event loops. These are the patterns that separate production-quality code from tutorials.

The Problem: Callback Hell

// Old way — deeply nested callbacks
getUser(userId, (err, user) => {
  if (err) return handleError(err)
  getPosts(user.id, (err, posts) => {
    if (err) return handleError(err)
    getComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err)
      console.log(comments)
    })
  })
})

Promises

// Promise chaining — better, but still verbose
getUser(userId)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => handleError(err))
  .finally(() => setLoading(false))

async/await — Clean and Readable

async function loadUserComments(userId) {
  try {
    const user = await getUser(userId)
    const posts = await getPosts(user.id)
    const comments = await getComments(posts[0].id)
    return comments
  } catch (err) {
    handleError(err)
  } finally {
    setLoading(false)
  }
}

Fetching Data with async/await

interface Post {
  id: number
  title: string
  body: string
  userId: number
}
 
async function fetchPost(id: number): Promise<Post> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
 
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`)
  }
 
  return response.json() as Promise<Post>
}
 
// Usage
const post = await fetchPost(1)
console.log(post.title)

Running Promises Concurrently

// Sequential — slow (requests run one after another)
const user = await fetchUser(1)     // wait
const posts = await fetchPosts(1)   // wait
 
// Concurrent — fast (requests run in parallel)
const [user, posts] = await Promise.all([
  fetchUser(1),
  fetchPosts(1),
])
 
// Race — use whichever resolves first
const result = await Promise.race([
  fetchFromPrimary(),
  fetchFromFallback(),
])
 
// allSettled — wait for all, don't fail on partial errors
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()])
results.forEach(r => {
  if (r.status === "fulfilled") console.log(r.value)
  else console.error(r.reason)
})
 
// any — resolve when first succeeds (reject only if all fail)
const fastest = await Promise.any([fetchA(), fetchB(), fetchC()])

Error Handling Patterns

// Per-call try/catch
async function getUserSafe(id: number) {
  try {
    return await fetchUser(id)
  } catch {
    return null  // fallback
  }
}
 
// Result pattern (avoids try/catch nesting)
async function safeAsync<T>(
  promise: Promise<T>
): Promise<[T, null] | [null, Error]> {
  try {
    const data = await promise
    return [data, null]
  } catch (err) {
    return [null, err as Error]
  }
}
 
const [user, err] = await safeAsync(fetchUser(1))
if (err) {
  console.error("Failed:", err.message)
} else {
  console.log(user.name)
}

AbortController — Cancellation

async function fetchWithTimeout(url: string, timeoutMs: number) {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), timeoutMs)
 
  try {
    const response = await fetch(url, { signal: controller.signal })
    return await response.json()
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      throw new Error("Request timed out")
    }
    throw err
  } finally {
    clearTimeout(timeout)
  }
}
 
// React example: cancel on unmount
useEffect(() => {
  const controller = new AbortController()
 
  fetch("/api/data", { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== "AbortError") setError(err)
    })
 
  return () => controller.abort()
}, [])

Async Iteration

async function* paginate(url: string) {
  let page = 1
  while (true) {
    const res = await fetch(`${url}?page=${page}`)
    const data = await res.json()
    if (!data.length) break
    yield data
    page++
  }
}
 
for await (const page of paginate("/api/users")) {
  console.log(`Fetched ${page.length} users`)
}

Common Mistakes

  • Using await inside forEach — it doesn't work; use for...of or Promise.all
  • Not handling the rejection of async functions — unhandled promise rejections crash Node.js
  • Running sequential awaits when they could be concurrent with Promise.all
  • Forgetting that async functions always return a Promise, even when returning a plain value
  • Mixing .then() and await in the same function — pick one style

Best Practices

  • Always await or .catch() every Promise — never let rejections go unhandled
  • Use Promise.all for independent concurrent operations to reduce total wait time
  • Use AbortController in React useEffect to cancel pending fetches on unmount
  • Prefer Promise.allSettled when partial failures are acceptable
  • Write a safeAsync wrapper for clean error handling without deeply nested try/catch

Key Takeaways

  • async/await is syntactic sugar over Promises — async functions always return a Promise
  • await pauses execution until the Promise resolves; errors propagate to the nearest try/catch
  • Promise.all runs Promises concurrently — failure of any rejects the whole call
  • Promise.allSettled waits for all, regardless of individual failure
  • AbortController + signal is the standard way to cancel fetch requests
  • for await...of iterates async generators — use it for paginated API responses
  • Never use await inside forEach — use for...of or Promise.all(arr.map(...))

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading