Episode 1 — Fundamentals / 1.17 — Asynchronous Programming JavaScript

1.17.b — Callbacks and Callback Problems

In one sentence: A callback is a function passed to another function for later execution — simple and powerful for basic async tasks, but callback hell, inversion of control, and poor error handling make callbacks alone insufficient for complex async flows.

Navigation: ← 1.17.a — Introduction to Asynchrony · 1.17.c — Understanding Promises →


1. What is a callback function?

A callback is any function you pass as an argument to another function, which then invokes it at some point.

function doSomething(callback) {
  console.log("Doing work...");
  callback();  // invoke the callback
}

doSomething(function () {
  console.log("Work is done!");
});
// Output: "Doing work...", "Work is done!"

The term comes from the idea of "calling back" — you hand off a function and say: "Call me back when you're done."


2. Synchronous callbacks vs asynchronous callbacks

Not all callbacks are async. The distinction is crucial:

2.1 Synchronous callbacks

These run immediately within the calling function's execution, before it returns.

// Array.forEach — synchronous callback
const nums = [1, 2, 3];
nums.forEach(function (n) {
  console.log(n);
});
console.log("Done");
// Output: 1, 2, 3, Done — all synchronous
// Array.map — synchronous callback
const doubled = [1, 2, 3].map(n => n * 2);
console.log(doubled); // [2, 4, 6] — callback ran for each element immediately

Other synchronous callbacks: Array.filter(), Array.reduce(), Array.sort().

2.2 Asynchronous callbacks

These are invoked later, after some async operation completes.

// setTimeout — asynchronous callback
console.log("Before");
setTimeout(function () {
  console.log("Inside timeout");
}, 1000);
console.log("After");
// Output: Before, After, Inside timeout (after ~1s)
TypeWhen callback runsExamples
SynchronousImmediately, blockingforEach, map, filter, sort, reduce
AsynchronousLater, after an event or delaysetTimeout, addEventListener, fs.readFile

3. Using callbacks with common async APIs

3.1 setTimeout

function delayedGreeting(name) {
  setTimeout(function () {
    console.log("Hello, " + name + "!");
  }, 2000);
}
delayedGreeting("Alice"); // prints after 2 seconds

3.2 Event listeners

document.getElementById("btn").addEventListener("click", function (event) {
  console.log("Button clicked!", event.target);
});
// The callback runs every time the button is clicked — not immediately

3.3 Node.js file reading

const fs = require("fs");

fs.readFile("data.txt", "utf8", function (err, data) {
  if (err) {
    console.error("Error reading file:", err);
    return;
  }
  console.log("File contents:", data);
});
console.log("This runs before the file is read");

4. Callback hell / pyramid of doom

When multiple async operations depend on each other sequentially, callbacks nest deeper and deeper:

// Fetch user, then their posts, then comments on the first post,
// then the profile of the first commenter
getUser(userId, function (err, user) {
  if (err) {
    console.error("Error fetching user:", err);
    return;
  }
  getPosts(user.id, function (err, posts) {
    if (err) {
      console.error("Error fetching posts:", err);
      return;
    }
    getComments(posts[0].id, function (err, comments) {
      if (err) {
        console.error("Error fetching comments:", err);
        return;
      }
      getUserProfile(comments[0].userId, function (err, profile) {
        if (err) {
          console.error("Error fetching profile:", err);
          return;
        }
        console.log("Commenter profile:", profile);
        // Imagine more levels...
      });
    });
  });
});

Problems with this pattern:

ProblemWhy it hurts
ReadabilityCode moves to the right, hard to follow the logic
MaintainabilityAdding a step means re-indenting everything
Error handlingEvery level needs its own if (err) check
DebuggingStack traces are hard to follow through nested closures

The visual shape of the code — a sideways pyramid — gives this pattern its name: pyramid of doom.

getUser(id, function (err, user) {
    getPosts(user.id, function (err, posts) {
        getComments(posts[0].id, function (err, comments) {
            getProfile(comments[0].userId, function (err, profile) {
                // ← deep nesting, hard to maintain
            });
        });
    });
});

5. Inversion of control

When you pass a callback to a third-party function, you hand over control of when (and whether) your code runs. This is called inversion of control (IoC).

// You trust thirdPartyLibrary to:
// 1. Call your callback exactly once
// 2. Call it with the right arguments
// 3. Not call it too early or too late
// 4. Handle errors before calling you

thirdPartyLibrary.process(data, function (result) {
  chargeCustomerCreditCard(result.amount); // dangerous if called twice!
});

Risks of inversion of control:

RiskWhat could happen
Callback called too many timesCredit card charged twice
Callback never calledUser waits forever, no error shown
Callback called too earlyBefore your setup code finishes
Callback called with wrong argumentsUnexpected undefined or wrong data type
Errors swallowed silentlyNo way to know something went wrong

Promises solve IoC by giving you the control back — you observe the result instead of handing off your code.


6. Error handling in callbacks: the error-first pattern

Node.js established the error-first callback convention (also called "errback"):

// Convention: callback(error, result)
// If error is null/undefined → success
// If error is truthy → something went wrong

function fetchData(url, callback) {
  // Simulated async operation
  setTimeout(function () {
    if (!url) {
      callback(new Error("URL is required"), null);
      return;
    }
    callback(null, { data: "some data from " + url });
  }, 1000);
}

// Usage
fetchData("https://api.example.com", function (err, result) {
  if (err) {
    console.error("Failed:", err.message);
    return;                     // ← must return to avoid running success code
  }
  console.log("Success:", result.data);
});

Rules of the error-first pattern:

  1. The first argument to the callback is always the error (or null on success).
  2. The remaining arguments are the success data.
  3. The caller must check err before using the result.
  4. Return immediately after handling the error to avoid executing success logic.

Limitation: This is a convention, not enforcement. Nothing prevents a library from breaking these rules.


7. Attempting to flatten callbacks (named functions)

One partial fix for callback hell is extracting named functions:

function handleProfile(err, profile) {
  if (err) return console.error(err);
  console.log("Profile:", profile);
}

function handleComments(err, comments) {
  if (err) return console.error(err);
  getUserProfile(comments[0].userId, handleProfile);
}

function handlePosts(err, posts) {
  if (err) return console.error(err);
  getComments(posts[0].id, handleComments);
}

function handleUser(err, user) {
  if (err) return console.error(err);
  getPosts(user.id, handlePosts);
}

getUser(userId, handleUser);

This flattens the indentation but introduces new problems:

  • Functions are defined in reverse order of execution (hard to follow the flow).
  • Sharing data between steps requires closures or passing extra context.
  • Still no centralized error handling.

8. Why callbacks alone are insufficient

LimitationConsequence
No composabilityCannot easily combine async results (parallel execution)
No chainingSequential steps require nesting or extracting named functions
No built-in error propagationEach level handles errors independently
Inversion of controlTrust issues with third-party code
No cancellationCannot cancel a pending callback-based operation
No standard interfaceEvery library can define its own callback signature

These problems motivated the creation of Promises (see 1.17.c), which provide a standardized, composable, chainable abstraction for async operations.


Key takeaways

  1. A callback is a function passed to another function for later invocation.
  2. Synchronous callbacks (forEach, map) run immediately; async callbacks (setTimeout, event listeners) run later.
  3. Callback hell occurs when sequential async operations create deeply nested, hard-to-maintain code.
  4. Inversion of control means you trust external code to call your callback correctly — a risky proposition.
  5. The error-first pattern (callback(err, result)) is a convention, not a guarantee.
  6. Callbacks are foundational but insufficient alone for complex async flows — Promises and async/await build on top of them.

Explain-It Challenge

Explain without notes:

  1. What is the difference between a synchronous callback and an asynchronous callback? Give one example of each.
  2. Show what callback hell looks like and explain why it is a problem.
  3. What does inversion of control mean in the context of callbacks, and why is it dangerous?

Navigation: ← 1.17.a — Introduction to Asynchrony · 1.17.c — Understanding Promises →