Skip to content
elephantoo

Callbacks

Lesson 26 of 34 12 min read

Asynchronous callbacks, error-first callbacks in Node and why "callback hell" happens.


A callback is a function you pass to another function so it can call you back — either right away or later, when something has happened. Callbacks are the foundation of asynchronous JavaScript. Even though modern code mostly uses promises and async/await, you'll meet callbacks in event listeners, timers, array methods and older Node APIs, and understanding their limits explains why promises exist.

Synchronous callbacks#

You've already used plenty. Array methods call your function immediately, once per element:

JavaScript
const prices = [100, 250, 40];

const doubled = prices.map((p) => p * 2); // callback runs 3 times, right now
console.log(doubled); // [ 200, 500, 80 ]

console.log("before");
prices.forEach((p) => console.log(p));
console.log("after");
Output
[ 200, 500, 80 ]
before
100
250
40
after

Nothing asynchronous here — "after" waits for every callback to finish.

Asynchronous callbacks#

Other APIs store your callback and call it later, when an event happens or work completes:

JavaScript
console.log("Ordering coffee…");

setTimeout(() => {
  console.log("☕ Coffee is ready!");
}, 1000);

console.log("Reading a book while I wait");
Output
Ordering coffee…
Reading a book while I wait
☕ Coffee is ready!

Browser events are callbacks too: button.addEventListener("click", handleClick).

Writing your own callback-based function#

Let's simulate a database lookup that takes time:

JavaScript
const users = { 1: { id: 1, name: "Ada" }, 2: { id: 2, name: "Alan" } };

function getUser(id, callback) {
  setTimeout(() => {
    const user = users[id];
    if (!user) {
      callback(new Error(`User ${id} not found`));
      return;
    }
    callback(null, user);
  }, 100);
}

getUser(1, (err, user) => {
  if (err) {
    console.error("Failed:", err.message);
    return;
  }
  console.log("Got", user.name);
});

getUser(99, (err, user) => {
  if (err) {
    console.error("Failed:", err.message);
    return;
  }
  console.log("Got", user.name);
});
Output
Got Ada
Failed: User 99 not found

The error-first convention

Node.js standardised error-first callbacks: the callback's first parameter is an error (or null), and the result comes after. You'll see it all over older Node code:

JavaScript
const fs = require("node:fs");

fs.readFile(__filename, "utf8", (err, text) => {
  if (err) {
    console.error("Could not read file:", err.code);
    return;
  }
  console.log(`This file has ${text.split("\n").length} lines`);
});

Rules of thumb for callback APIs:

  • Always handle err first and return so you don't continue with a missing result.
  • Call the callback exactly once.
  • Be consistently async. A function that sometimes calls back immediately and sometimes later (sometimes called "releasing Zalgo") causes subtle ordering bugs.

Why try/catch doesn't help

JavaScript
try {
  setTimeout(() => {
    // An error thrown here is NOT caught by the outer try,
    // because the try block finished long ago.
  }, 100);
} catch (err) {
  console.log("never reached");
}

That's why callback APIs pass errors as arguments instead of throwing them.

Callback hell#

Problems start when async steps depend on each other. Suppose we need to get a user, then their orders, then the first order's shipping status:

JavaScript
function getOrders(userId, cb) {
  setTimeout(() => cb(null, [{ id: 501, userId }]), 100);
}
function getShipping(orderId, cb) {
  setTimeout(() => cb(null, { orderId, status: "out for delivery" }), 100);
}

getUser(1, (err, user) => {
  if (err) return console.error(err.message);
  getOrders(user.id, (err, orders) => {
    if (err) return console.error(err.message);
    getShipping(orders[0].id, (err, shipping) => {
      if (err) return console.error(err.message);
      console.log(`${user.name}'s order ${shipping.orderId} is ${shipping.status}`);
      // ...and the next step would nest even deeper
    });
  });
});
Output
Ada's order 501 is out for delivery

This "pyramid of doom" has real problems:

  • Readability: the logic drifts right and is hard to follow.
  • Error handling is repeated at every level — forget one and errors vanish.
  • Running steps in parallel, or "whichever finishes first", needs manual counters and flags.
  • Inversion of control: you hand your callback to someone else's code and trust them to call it once, with the right arguments, at the right time.

Taming it (a little)

Naming the steps flattens the pyramid:

JavaScript
function showShipping(user) {
  getOrders(user.id, (err, orders) => {
    if (err) return console.error(err.message);
    getShipping(orders[0].id, (err, shipping) => {
      if (err) return console.error(err.message);
      console.log(`${user.name}: ${shipping.status}`);
    });
  });
}

getUser(2, (err, user) => (err ? console.error(err.message) : showShipping(user)));

It helps, but the fundamental issues remain. The real fix is promises.

From callbacks to promises#

Node can convert any error-first callback function into a promise-returning one:

JavaScript
const { promisify } = require("node:util");
const getUserAsync = promisify(getUser);

getUserAsync(1)
  .then((user) => console.log("Promise says:", user.name))
  .catch((err) => console.error(err.message));

And most Node modules already have promise versions, such as node:fs/promises:

JavaScript
import { readFile } from "node:fs/promises";
const text = await readFile("notes.txt", "utf8");

Preview of where we're heading — the callback pyramid rewritten with async/await:

JavaScript
async function showStatus(userId) {
  try {
    const user = await getUserAsync(userId);
    const orders = await promisify(getOrders)(user.id);
    const shipping = await promisify(getShipping)(orders[0].id);
    console.log(`${user.name}'s order is ${shipping.status}`);
  } catch (err) {
    console.error(err.message);
  }
}

Flat, readable, with one place to handle errors.

Where callbacks are still the right tool#

  • Events that happen many times: addEventListener, Node's stream.on("data", ...). Promises resolve only once.
  • Synchronous helpers: map, filter, sort comparators.
  • Configuration hooks: onSuccess, onError options in libraries.

Common mistakes#

  • Forgetting to return after calling back with an error, so the success code runs too.
  • Calling the callback twice (e.g. once in an error branch and again later).
  • Calling the function instead of passing it: setTimeout(done(), 100).
  • Expecting try/catch around an async call to catch errors thrown inside its callback.

What's next#

Next: promises — objects that represent a future value, and the foundation of modern asynchronous JavaScript.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In Node's error-first callback convention, what is the first argument to the callback?

  2. 2.What is "callback hell"?

  3. 3.Given [3, 1, 2].forEach((n) => console.log(n)); console.log("done");, when is "done" printed?

Finished reading?

Mark this lesson complete to track your progress.