Callback Hell

Understand what callback hell is, why it happens, and how to avoid it with cleaner async patterns.

Loading...
Callback Hell

What is Callback Hell?

Callback hell happens when you write many callbacks inside callbacks, one after another, in asynchronous code, especially when each task depends on the result of the previous one.

Each task waits for the previous one to finish, so the code keeps going deeper and deeper.

This makes the code look messy and hard to read or fix.

For example:

getUser((user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log("First comment:", comments[0]);
    });
  });
});

This is called the pyramid of doom or callback hell it gets worse as your app grows.


Why Does This Happen?

JavaScript is asynchronous, which means it doesn't wait for tasks to finish.

You often need to:

  • Read a file
  • Fetch data from an API
  • Delay something using setTimeout

And you usually handle those with callbacks. But when you nest callbacks to run things one after another, the code becomes hard to read and maintain. That’s when you run into callback hell.


Why Callback Hell is a Problem

  • Hard to read: The more you nest, the more confusing it gets.
  • Difficult to handle errors: You have to check errors in every nested level.
  • No clear structure or flow: It’s hard to follow what runs first or next.
  • Not scalable: Adding more steps means more nesting, making it worse.
  • Breaks return logic: return statements inside callbacks don’t work as expected across layers.

Example of Callback Hell

setTimeout(() => {
  console.log("1");
  setTimeout(() => {
    console.log("2");
    setTimeout(() => {
      console.log("3");
    }, 1000);
  }, 1000);
}, 1000);
 
// Output:
 
// 1
// 2
// 3

It works, but it’s ugly and hard to debug.


How to Avoid Callback Hell

Use Named Functions

function step3() {
  console.log("3");
}
 
function step2() {
  console.log("2");
  setTimeout(step3, 1000);
}
 
function step1() {
  console.log("1");
  setTimeout(step2, 1000);
}
 
setTimeout(step1, 1000);

Still callbacks, but easier to read.

Use Promises

doTask1().then(doTask2).then(doTask3).catch(handleError);

Promises solve callback hell by giving:

  • Cleaner flow: No deep nesting. Just chain .then() calls.
  • Flat structure: Each step follows the next in a readable line-by-line format.
  • Centralized error handling: Use a single .catch() to handle all errors in the chain.

Use async/await

async function run() {
  try {
    const result1 = await doTask1();
    const result2 = await doTask2(result1);
    const result3 = await doTask3(result2);
    console.log(result3);
  } catch (err) {
    console.error(err);
  }
}

async/await makes asynchronous code feel like regular step-by-step logic and this is easy to debug.


When You Might Still Use Callbacks

While Promises and async/await are usually better, you might still use callbacks in some cases:

  • Node.js APIs: Many core Node.js functions still use callbacks (e.g. fs.readFile).
  • Event Handlers: Button clicks, mouse moves, form submits.
  • Legacy Code: Older codebases that haven’t switched to Promises.
  • Simple One-Off Tasks: A single small async action that doesn't need chaining.

Comparison: Callbacks vs Promises vs async/await

Let’s see how these three ways of handling asynchronous tasks compare, using the same example of getting some data step by step.

Callbacks (Callback Hell)

getData(function (a) {
  getMoreData(a, function (b) {
    getEvenMoreData(b, function (c) {
      // Use c
    });
  });
});
  • The code quickly becomes hard to read.
  • Handling errors is tricky because you need to check at every level.
  • The flow isn’t clear, it’s nested deep like a pyramid.

Promises

getData()
  .then((a) => getMoreData(a))
  .then((b) => getEvenMoreData(b))
  .then((c) => {
    // Use c
  })
  .catch((error) => {
    // Handle error
  });
  • Much cleaner and easier to read.
  • Flat, straight line of code instead of nested blocks.
  • One place to handle errors, the .catch() at the end.

async/await

async function example() {
  try {
    const a = await getData();
    const b = await getMoreData(a);
    const c = await getEvenMoreData(b);
    // Use c
  } catch (error) {
    // Handle error
  }
}
  • Looks just like regular, synchronous code.
  • Very easy to follow step by step.
  • Debugging is simpler because it feels like normal code.

Support my work!