Chapter 9.1☕ 20 min read

Callbacks, Callback Hell & Error-First Pattern

Pehle sirf callbacks the — function do, baad mein call karunga. 3 nested = pyramid of doom. Phir Promises aaye aur sab theek hua.

01What Are Callbacks: Sync vs Async

Callback = a function passed as an argument, to be called later. You hand someone a function and say "call this when you're done."

Synchronous callback: array.map(callback) — called immediately in the same call stack. The thread is blocked until it returns.

Asynchronous callback: setTimeout(callback, 1000) — called later in a different call stack turn. The thread is free while waiting.

The key difference: Sync callbacks block the thread. Async callbacks yield control back to the event loop.

ALL JavaScript async is built on callbacks at the lowest level — even Promises and async/await compile down to callbacks. Web APIs (setTimeout, fetch, addEventListener) accept callbacks because they operate on separate browser threads.

// Synchronous callback — runs NOW
const numbers = [1, 2, 3];
const doubled = numbers.map(n => n * 2); // map calls callback immediately
console.log(doubled); // [2, 4, 6] — same call stack

// Asynchronous callback — runs LATER
console.log("1");
setTimeout(() => console.log("2"), 0); // Callback queued for later
console.log("3");
// Output: 1, 3, 2 — even with 0ms delay!
// Why? setTimeout queues the callback as a macrotask.
// Current call stack must clear first (1, 3), THEN callback runs (2).

// Event listener callback — runs on user action
button.addEventListener("click", function handleClick() {
  // This callback runs when user clicks — could be seconds later
  console.log("Button clicked!");
});
02Callback Hell: The Pyramid of Doom

Callback hell = deeply nested callbacks where each async step depends on the previous result. Also called the "pyramid of doom" because indentation forms a pyramid shape pointing right.

Problem 1: Hard to read — deeply nested, flow goes top-to-bottom-to-right, not linear. Your eyes zigzag across the screen.

Problem 2: Hard to debug — stack traces show "anonymous" for every callback. Where did the error originate? Good luck finding out.

Problem 3: Error handling is painful — must check for errors at EVERY level. Forget one check and your code proceeds with undefined data.

Problem 4: Inversion of control — you give your callback to someone else. What if they call it twice? Never? With wrong args?

Real example: getUsergetOrdersgetOrderDetailsprocessPaymentsendEmail — 5 levels deep, 5 error checks.

// Callback hell — 4 async steps, each depends on previous
getUser(userId, (err, user) => {
  if (err) { handleError(err); return; }
  
  getOrders(user.id, (err, orders) => {
    if (err) { handleError(err); return; }
    
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) { handleError(err); return; }
      
      processPayment(details.total, (err, receipt) => {
        if (err) { handleError(err); return; }
        
        sendEmail(user.email, receipt, (err) => {
          if (err) { handleError(err); return; }
          console.log("Done! Receipt:", receipt.id);
        });
      });
    });
  });
});
// 5 levels deep. Error check at EVERY level. Inversion of control on every call.
// This is the "pyramid of doom". Hard to read, hard to debug, hard to maintain.
Why V8 stack traces are useless in callback hell: Each callback runs in a new call stack turn, so Error.stack only shows the current callback's frames — not the chain of callbacks that led to it. This is WHY Promises were created — they preserve the error chain across async boundaries.
03Error-First Callback Pattern (Node.js Convention)

Node.js convention: callback's first argument is ALWAYS the error (null if success), second argument is the result.

Pattern: function callback(err, result) — if err is truthy, something went wrong.

This pattern standardizes error handling but still requires checking at every level. Many Node.js APIs use this: fs.readFile, http.get, crypto.pbkdf2.

The problem: if you forget to check err, your code silently proceeds with undefined result — and crashes later in unpredictable ways.

// Error-first callback pattern (Node.js style)
const fs = require("fs"); // Node.js example

fs.readFile("config.json", "utf8", (err, data) => {
  if (err) {
    console.error("Failed to read:", err.message);
    return; // MUST return! Otherwise code continues with undefined data.
  }
  // If we reach here, err is null and data is valid
  const config = JSON.parse(data);
  console.log("Config loaded:", config);
});

// The repeated error check pattern in callback hell:
step1((err, result1) => {
  if (err) return handleError(err);        // Check 1
  step2(result1, (err, result2) => {
    if (err) return handleError(err);      // Check 2
    step3(result2, (err, result3) => {
      if (err) return handleError(err);    // Check 3
      step4(result3, (err, result4) => {
        if (err) return handleError(err);  // Check 4
        // Finally! 4 error checks for 4 steps.
        console.log("All done:", result4);
      });
    });
  });
});
// Compare: With Promises, ONE .catch() handles ALL errors.
04Inversion of Control & Zalgo

Inversion of control: You write a callback and give it to a library. The library OWNS when and how it's called. You lose control.

Danger 1: Callback called more than once (library bug) — your code runs twice unexpectedly.

Danger 2: Callback never called (library bug or condition) — your code hangs silently.

Danger 3: Callback called with unexpected arguments — silent corruption.

Danger 4: Callback called synchronously sometimes and asynchronously other times — Zalgo!

Zalgo is a callback that is sometimes sync and sometimes async, creating unpredictable behavior. Code works in testing (sync path) but fails in production (async path).

Zalgo prevention: ALWAYS call callbacks asynchronously — setTimeout(callback, 0) ensures async even if result is ready.

// Inversion of control — you lose control of your callback
function dangerousLibrary(data, callback) {
  // Bug: callback called TWICE!
  callback(null, data);
  // ... later in code ...
  callback(null, data); // Your callback runs AGAIN! Double execution!
  
  // Bug: callback never called
  if (someCondition) {
    callback(null, result); // Only called if condition is true
  }
  // If condition is false, callback is NEVER called. Silent hang.
}

// Zalgo — sometimes sync, sometimes async
function maybeAsync(cache, key, callback) {
  if (cache[key]) {
    callback(null, cache[key]); // SYNC! Callback runs immediately!
  } else {
    fetch(key).then(data => {
      callback(null, data); // ASYNC! Callback runs later!
    });
  }
  // Consumer can't predict timing. Breaks assumptions.
}

// Zalgo prevention — ALWAYS async
function alwaysAsync(cache, key, callback) {
  if (cache[key]) {
    setTimeout(() => callback(null, cache[key]), 0); // Always async!
  } else {
    fetch(key).then(data => callback(null, data));
  }
}
The #1 rule of API design: A callback must be either ALWAYS synchronous or ALWAYS asynchronous. Never mix. If you're not sure, make it always async with setTimeout(callback, 0). This is called "releasing Zalgo" — it makes behavior predictable.
05Why Promises Won: Callback Problems Solved

Promises solve ALL callback problems. Here's the complete comparison:

1. Chainable — no pyramid, linear .then() chain. Each step returns a new Promise.

2. Single error channel — one .catch() handles all errors in the chain. No more error checks at every level.

3. Guaranteed async — Promises always resolve/reject asynchronously, no Zalgo possible.

4. ComposablePromise.all, Promise.race, Promise.any for parallel operations.

5. Once settled, never changes — a resolved Promise stays resolved. No double-call bug.

6. Error propagation — errors bubble up the chain automatically, like try/catch.

The callback era is over for new code. But understanding callbacks is essential for: legacy code, Node.js APIs, and understanding what Promises fix.

// Same 4-step flow — Callbacks vs Promises vs Async/Await

// CALLBACKS: Pyramid of doom
getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return handleError(err);
      processPayment(details.total, (err, receipt) => {
        if (err) return handleError(err);
        console.log("Done:", receipt);
      });
    });
  });
});

// PROMISES: Flat, linear chain
getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => processPayment(details.total))
  .then(receipt => console.log("Done:", receipt))
  .catch(err => handleError(err)); // ONE catch for ALL errors!

// ASYNC/AWAIT: Even cleaner (syntactic sugar over Promises)
async function processOrder(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const receipt = await processPayment(details.total);
    console.log("Done:", receipt);
  } catch (err) {
    handleError(err); // One try/catch for all steps
  }
}

Lo kar liya — Key Points:

  • ✅ A callback is a function passed as an argument to be called later — sync callbacks run immediately, async callbacks run later
  • ✅ Callback hell (pyramid of doom) occurs when each async step depends on the previous result, creating deeply nested code
  • ✅ Error-first callback pattern (Node.js convention) uses (err, result) where err is always the first argument
  • ✅ Inversion of control means you give your callback to someone else — they decide when/how/if it's called
  • ✅ Zalgo is a callback that is sometimes sync and sometimes async — always make callbacks consistently one or the other
  • ✅ Promises solve callback problems: linear chaining, single error channel, guaranteed async, composable, no double-call
  • ✅ One .catch() handles all errors in a Promise chain — no need for error checks at every level
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase