Callback Hell & How to Avoid It
"Callback hell" — also called the pyramid of doom — is what happens when dependent async steps nest inside one another's callbacks. The code drifts ever rightward, error handling repeats at every level, and following the logic becomes a chore. It's not just ugly; it's a real source of bugs. This page shows the problem and the four escapes, from quick fixes to the modern answer.
The pyramid of doom
getUser(1, (err, user) => {
if (err) return handle(err)
getOrders(user.id, (err, orders) => {
if (err) return handle(err)
getItems(orders[0].id, (err, items) => {
if (err) return handle(err)
getPrice(items[0].sku, (err, price) => {
if (err) return handle(err)
console.log(price) // ← five levels deep just to get here
})
})
})
})Why it's actually harmful
Repetitive error handling — the same
if (err) returnat every level, easy to forget one.Rightward drift — deep indentation makes the real logic hard to find and review.
Hard to refactor — reordering or adding a step means surgery on the nesting.
Error-prone control flow — early returns, shared variables, and manual counters invite mistakes.
No composition — you cannot easily
map/filter/combine a tangle of nested callbacks.
Escape 1: named functions
The cheapest fix that needs no new API — give each step a name and flatten the pyramid into a sequence. It helps, but the wiring is still manual:
function start() { getUser(1, onUser) }
function onUser(err, user) {
if (err) return handle(err)
getOrders(user.id, onOrders)
}
function onOrders(err, orders) {
if (err) return handle(err)
getItems(orders[0].id, onItems)
}
function onItems(err, items) {
if (err) return handle(err)
console.log(items)
}
start()Escape 2: promises and chaining
Promise-returning functions .then into a flat chain — and a single .catch handles errors from every step, eliminating the repeated checks:
getUser(1) .then((user) => getOrders(user.id)) .then((orders) => getItems(orders[0].id)) .then((items) => getPrice(items[0].sku)) .then((price) => console.log(price)) .catch(handle) // one handler for the whole chain
Escape 3: async / await (the modern answer)
async/await lets you write asynchronous steps as if they were synchronous — linear, with ordinary try/catch. This is what you should reach for in new code:
async function getPriceForUser(id) {
try {
const user = await getUser(id)
const orders = await getOrders(user.id)
const items = await getItems(orders[0].id)
const price = await getPrice(items[0].sku)
return price
} catch (err) {
handle(err) // catches any step's failure
}
}// ✗ Slow — 3 sequential round-trips (sum of all) const a = await getUser(1) const b = await getUser(2) const c = await getUser(3) // ✓ Fast — 3 concurrent round-trips (max of all) const [a2, b2, c2] = await Promise.all([getUser(1), getUser(2), getUser(3)])
Escape 4: promisify the old API
When you're stuck with a callback-based function, wrap it once with util.promisify and then use it with await like any modern API:
import { promisify } from 'node:util'
import { readFile } from 'node:fs'
const readFileP = promisify(readFile)
const text = await readFileP('file.txt', 'utf8') // no callback nestingDetails and edge cases in util.promisify.
The progression
Approach | Fixes nesting? | Unified errors? | Recommended? |
|---|---|---|---|
Nested callbacks | No | No | Avoid |
Named functions | Partly | No | Legacy stopgap |
Promise chains | Yes | Yes ( | Good |
| Yes | Yes ( | Best |