Skip to content
JavaScript js async 4 min read

Callbacks & Callback Hell

A callback is simply a function you pass to another function so it can be called back later — often after an asynchronous task finishes. Before promises and async/await existed, callbacks were the only way JavaScript expressed “do this when the work is done.” They still power timers, DOM events, streams, and countless older APIs, so understanding them is essential — including why deeply nested callbacks earn the nickname callback hell.

What is a callback?

A callback is a function passed as an argument to another function, to be invoked at some point — either immediately (synchronously) or later (asynchronously). Array methods like map and forEach use synchronous callbacks. The interesting case for async work is when the callback runs after the current code finishes, once an event or timer fires.

// Synchronous callback — runs right now, in order
[1, 2, 3].forEach((n) => console.log(n));

// Asynchronous callback — runs later, after the timer elapses
setTimeout(() => console.log("ran after 1 second"), 1000);
console.log("ran first");

Output:

1
2
3
ran first
ran after 1 second

The setTimeout callback is deferred until the call stack is empty and the timer expires, which is why "ran first" prints before it. This deferral is the heart of asynchronous JavaScript.

Callbacks for events and old APIs

Many browser and Node APIs are callback-driven. You register a function and the runtime invokes it when something happens.

// DOM event listener — callback fires on every click
button.addEventListener("click", (event) => {
  console.log(`clicked at ${event.clientX}, ${event.clientY}`);
});

// Node's classic fs API — callback fires when the read completes
import { readFile } from "node:fs";

readFile("config.json", "utf8", (err, data) => {
  if (err) {
    console.error("Failed to read file:", err.message);
    return;
  }
  console.log("File contents:", data);
});

Error-first callbacks

Node.js standardized a convention called the error-first (or errback) pattern: the callback’s first argument is an error (or null if none), and subsequent arguments hold the result. This gives async code a consistent way to report failures, since you cannot simply try/catch around a callback that runs later.

function loadUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid id"));
      return;
    }
    callback(null, { id, name: "Ada" });
  }, 100);
}

loadUser(1, (err, user) => {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log(`Loaded ${user.name}`);
});

Tip: Always check the error argument first and return early. Forgetting this lets execution continue with undefined data and produces confusing downstream bugs.

The pyramid of doom

Trouble starts when one async step depends on the result of the previous one. Each callback nests inside the last, and the code drifts right into a triangular shape — the pyramid of doom.

loadUser(1, (err, user) => {
  if (err) return console.error(err);
  loadOrders(user.id, (err, orders) => {
    if (err) return console.error(err);
    loadItems(orders[0].id, (err, items) => {
      if (err) return console.error(err);
      loadPrice(items[0].sku, (err, price) => {
        if (err) return console.error(err);
        console.log(`Price: ${price}`);
      });
    });
  });
});

This is callback hell. The problems compound quickly:

ProblemWhy it hurts
Deep nestingHard to read; logic flows right then back left
Repeated error handlingEvery level repeats the same if (err) boilerplate
No return valuesYou cannot return a result out of a callback
Inversion of controlYou trust a third party to call your callback once, correctly
Hard compositionRunning steps in parallel or with timeouts gets gnarly

That last point — inversion of control — is subtle but serious. When you hand your callback to another function, you lose control over whether it is called too many times, never, too early, or with the wrong arguments.

Motivating promises

A Promise is an object representing a future value. Instead of passing a callback in, an async function hands a promise back, which you attach handlers to with .then() and .catch(). Promises flatten the pyramid into a linear chain and centralize error handling.

loadUser(1)
  .then((user) => loadOrders(user.id))
  .then((orders) => loadItems(orders[0].id))
  .then((items) => loadPrice(items[0].sku))
  .then((price) => console.log(`Price: ${price}`))
  .catch((err) => console.error(err));

async/await goes further, letting asynchronous steps read like ordinary synchronous code while keeping a normal try/catch.

async function showPrice() {
  try {
    const user = await loadUser(1);
    const orders = await loadOrders(user.id);
    const items = await loadItems(orders[0].id);
    const price = await loadPrice(items[0].sku);
    console.log(`Price: ${price}`);
  } catch (err) {
    console.error(err);
  }
}

Gotcha: You cannot await a callback-based API directly. Wrap it in a promise first (or use Node’s util.promisify / the node:fs/promises module) to bring legacy callbacks into modern flows.

Best practices

  • Prefer promises and async/await for new code; reserve raw callbacks for true event streams like DOM events and Node EventEmitters.
  • Follow the error-first convention when you must write a callback API, and always handle the error before the data.
  • Keep callbacks small and named instead of deeply nested anonymous functions — naming flattens the pyramid and aids stack traces.
  • Promisify legacy callback APIs at the boundary with util.promisify so the rest of your code stays modern.
  • Never call a callback more than once; guard with a called flag if a code path could fire it twice.
  • Avoid mixing synchronous and asynchronous callback behavior in one function — always defer consistently to prevent subtle ordering bugs.
Last updated June 1, 2026
Was this helpful?