Callbacks & Callback Hell

Reviewed & published by Brayan K

A callback is just a function you hand to another function to run later. It's the original way JavaScript handled "do this when the work is done" — clicks, timers, file reads, network requests — and it's still everywhere.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

This lesson is your gateway to asynchronous JavaScript. You'll see why callbacks are powerful, how nesting them creates the dreaded "callback hell", and why Promises and async/await came next.

What You'll Learn in This Lesson

📚 Prerequisites: You need a solid grip on functions (especially passing functions as arguments). This lesson sets up Promises and async/await, which come next.

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

📞 Real-World Analogy: A callback is like leaving your number at a busy restaurant:

1️⃣ A Callback Is Just a Function You Pass Along

Because functions are values in JavaScript, you can pass one to another function and let it decide when to call it. You've used this already with map and forEach — those take a callback they run on each item, right away (synchronously).

// greet RECEIVES a function and calls it back
function greet(name, formatter) {
  return "Hello, " + formatter(name);
}

const shout = (n) => n.toUpperCase();
const whisper = (n) => n.toLowerCase();

console.log(greet("Ada", shout));   // ✅ Expected output: Hello, ADA
console.log(greet("Bo", whisper));  // ✅ Expected output: Hello, bo

// forEach is a built-in that takes a callback per item
["x", "y"].forEach((item, i) => console.log(i, item));
// ✅ Expected output:
// 0 x
// 1 y

2️⃣ Asynchronous Callbacks Run Later

The interesting case is asynchronous callbacks. With setTimeout, JavaScript schedules your callback and keeps running. The result arrives after the surrounding code has already moved on — which is exactly why the log order surprises beginners.

console.log("1: before");

setTimeout(() => {
  console.log("2: inside callback (runs later)");
}, 0);

console.log("3: after");

// ✅ Expected output (note the order!):
// 1: before
// 3: after
// 2: inside callback (runs later)

3️⃣ Why You Can't Just return the Result

A classic mistake: trying to return a value out of an async operation. The outer function finishes long before the callback runs, so there's nothing to return yet. The fix is to continue your work inside the callback, or pass another callback in.

function fetchScore(callback) {
  // Pretend this takes time (e.g. a network request)
  setTimeout(() => {
    callback(42); // hand the result back through the callback
  }, 0);
}

// ❌ This does NOT work — the value isn't ready yet:
// const score = fetchScore(); console.log(score); // undefined

// ✅ Continue your logic inside the callback:
fetchScore((score) => {
  console.log("Score is " + score); // ✅ Expected output: Score is 42
});

🎯 Your Turn #1 — Pass a Callback

Call afterDelay with a function that logs done!.

function afterDelay(cb) {
  setTimeout(cb, 0); // runs your callback shortly
}

// 👉 replace ___ with a function that logs "done!"
afterDelay(___);

// ✅ Expected output: done!

4️⃣ The Error-First Convention

Node.js made a convention popular: a callback's first argument is an error (or null if all went well), and the data comes after. You check the error first, then proceed. It's simple and consistent — but repeating that check at every level is part of what makes deep nesting painful.

function loadUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid id"), null);
    } else {
      callback(null, { id, name: "User" + id });
    }
  }, 0);
}

loadUser(7, (err, user) => {
  if (err) {
    console.log("Error:", err.message);
  } else {
    console.log("Loaded:", user.name); // ✅ Expected output: Loaded: User7
  }
});

loadUser(-1, (err, user) => {
  if (err) {
    console.log("Error:", err.message); // ✅ Expected output: Error: Invalid id
  }
});

5️⃣ Callback Hell — The Pyramid of Doom

When several async steps must happen in order, each depending on the last, you end up nesting callbacks inside callbacks. The indentation marches to the right and the code gets hard to follow. This shape — the "pyramid of doom" — is callback hell.

function step(name, cb) {
  setTimeout(() => {
    console.log("Finished " + name);
    cb();
  }, 0);
}

// Three steps that must run in order → growing nesting
step("login", () => {
  step("load profile", () => {
    step("load posts", () => {
      console.log("All done!");
    });
  });
});

// ✅ Expected output:
// Finished login
// Finished load profile
// Finished load posts
// All done!

6️⃣ Taming the Pyramid (Before Promises)

You can soften callback hell by giving each step a named function instead of an inline arrow. The nesting flattens and each stage reads as a clear, labelled unit. (The real cure, Promises and async/await, comes in the next lessons — this is the bridge.)

function step(name, cb) {
  setTimeout(() => { console.log("Finished " + name); cb(); }, 0);
}

// Name each stage instead of nesting inline arrows
function start()       { step("login", afterLogin); }
function afterLogin()  { step("load profile", afterProfile); }
function afterProfile(){ step("load posts", afterPosts); }
function afterPosts()  { console.log("All done!"); }

start();

// ✅ Expected output:
// Finished login
// Finished load profile
// Finished load posts
// All done!

🎯 Your Turn #2 — Handle the Error First

Complete the error-first check so a failure logs the message.

function run(callback) {
  setTimeout(() => callback(new Error("boom"), null), 0);
}

run((err, data) => {
  // 👉 replace ___ so we handle the error before using data
  if (___) {
    console.log("Caught:", err.message); // ✅ Expected output: Caught: boom
    return;
  }
  console.log("Data:", data);
});

🧩 Reorder Challenge

These lines are scrambled. Reorder them so the callback is defined, then passed and run, logging ready.

Why: both functions are declarations (hoisted), so it runs either way — but declaring before the call reads cleanly: define run and the callback, then invoke.

🧠 Quick Recall

Predict the output before revealing the answer.

a, then c, then b — the setTimeout callback is deferred, so it runs after the synchronous code.

1 then 2 — forEach runs its callback synchronously, once per element, in order.

21 — the callback returns 2 * 10 = 20, and f adds 1.

Common Errors & Fixes

Quick Reference — Callbacks

IdeaDetail
CallbackA function passed to be called later
Sync callbackmap, filter, forEach — runs immediately
Async callbacksetTimeout, events, requests — runs later
Pass, don't callsetTimeout(fn, 0) not fn()
Error-firstcb(err, data) — check err first
Callback hell fixNamed functions → Promises → async/await

Lesson Complete — Callbacks & Callback Hell!

Practice quiz

What is a callback in JavaScript?

  • A function returned from a Promise
  • A special async keyword
  • A function you pass to another function to be called later
  • A built-in error type

Answer: A function you pass to another function to be called later. A callback is simply a function handed to another function to run at some point, immediately or later.

Are array methods like map and forEach synchronous or asynchronous callbacks?

  • Synchronous
  • Asynchronous
  • Neither
  • Both at once

Answer: Synchronous. map and forEach run their callback right away, once per item, synchronously.

What order do these log: console.log('1'); setTimeout(() => console.log('2'), 0); console.log('3')?

  • 1, 2, 3
  • 3, 2, 1
  • 2, 1, 3
  • 1, 3, 2

Answer: 1, 3, 2. The setTimeout callback is deferred, so it runs after the synchronous code: 1, then 3, then 2.

Why can't you just return a value out of an asynchronous callback's surrounding function?

  • return is forbidden in async code
  • The outer function finishes before the callback runs, so the result isn't ready
  • Callbacks can't return values
  • It causes an infinite loop

Answer: The outer function finishes before the callback runs, so the result isn't ready. The surrounding function returns long before the async callback fires, so there is nothing to return yet.

In the error-first convention, what is the callback's first argument?

  • An error (or null if all went well)
  • The data
  • A status code
  • The callback itself

Answer: An error (or null if all went well). Node popularized callback(err, data): the first argument is an error, or null on success.

What is 'callback hell' (the pyramid of doom)?

  • A runtime crash
  • Calling a callback twice
  • Deeply nested callbacks that march indentation to the right and hurt readability
  • An error in the event loop

Answer: Deeply nested callbacks that march indentation to the right and hurt readability. Nesting callback inside callback for sequential steps creates the hard-to-read pyramid of doom.

What does f return for function f(cb) { return cb(2) + 1; } called as f(x => x * 10)?

  • 20
  • 21
  • 3
  • 30

Answer: 21. The callback returns 2 * 10 = 20, then f adds 1, giving 21.

What is one way to flatten callback hell before using Promises?

  • Use more var declarations
  • Remove all error checks
  • Use synchronous loops
  • Give each step a named function instead of inline arrows

Answer: Give each step a named function instead of inline arrows. Naming each stage as its own function flattens the nesting and makes the flow readable.

What is the difference between setTimeout(doWork, 1000) and setTimeout(doWork(), 1000)?

  • No difference
  • The first passes the function; the second calls doWork now and passes its result
  • The second is faster
  • The first throws an error

Answer: The first passes the function; the second calls doWork now and passes its result. doWork() runs immediately and passes its return value; you want to pass the function itself: doWork.

What replaced callbacks as the modern way to chain asynchronous steps?

  • var and let
  • Synchronous loops
  • Promises and async/await
  • Global variables

Answer: Promises and async/await. Promises and then async/await express the same sequence as flat code with unified error handling.

Continue this course

Frequently asked questions

What exactly is a callback?

A callback is simply a function you pass to another function, to be called back later. Array methods like map and forEach take synchronous callbacks that run immediately. Asynchronous APIs like setTimeout or a network request take callbacks that run later, once the work finishes. The pattern is the same; only the timing differs.

What is 'callback hell'?

Callback hell (the 'pyramid of doom') is what happens when you nest callback inside callback to run async steps in sequence — each one indented further right than the last. The code becomes hard to read, error handling gets repetitive, and the logic drifts off the right edge of the screen. It's a readability problem, not a correctness one.

Why can't I just return a value from an asynchronous callback?

Because the surrounding function has already finished and returned by the time the callback runs. The result doesn't exist yet when the outer return executes. That's why async APIs hand you the result THROUGH the callback instead — you continue your work inside it, not after the call.

What's the error-first callback convention?

Node.js popularised callbacks whose first parameter is an error: callback(err, data). You check if (err) first and handle it, otherwise use data. It's a simple, consistent contract, but because every level repeats the check, deeply nested error-first callbacks are a big driver of callback hell.

If callbacks are messy, what replaced them?

Promises and then async/await. They express the same 'do this, then that' sequence as flat, top-to-bottom code with unified error handling via try/catch. Callbacks are still everywhere (event listeners, array methods), but for chaining asynchronous steps, Promises and async/await are the modern answer — and the next lessons in this course.

Related lessons