- Published on
JavaScript Async/Await - Stop Writing Callback Hell
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Step 1: Promises
- Step 2: Async/Await
- The Rules of Async/Await
- Error Handling with try/catch
- Running Multiple Requests in Parallel
- Promise.allSettled() — Don't Fail on One Error
- Promise.race() and Promise.any()
- Async Iteration
- Real-World Pattern: Retry Logic
- Conclusion
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.