AlgoInsight

Free lesson · JavaScript Basic to Advance

Callbacks: The Original Async Pattern

Easy5 min read

Before Promises, There Were Callbacks

A callback is a function passed to another function, with the expectation that it will be called when some operation completes. Before Promises and async/await, callbacks were the only way to handle async operations in JavaScript. They still appear constantly in modern code — every addEventListener, every setTimeout, every old Node API uses them.

console.log("Before timeout");

setTimeout(() => {
  console.log("Inside the callback");
}, 100);

console.log("After timeout (synchronous)");

The output: Before timeout, then After timeout (synchronous), then Inside the callback. The setTimeout doesn't pause — it schedules a callback and continues. Later, the callback runs.

This pattern is the foundation of async JavaScript. Understanding it makes Promises and async/await make sense, because they're built on top of the same callback mechanism — just with cleaner syntax.

This Topic covers callbacks, the convention that emerged around them in Node.js, and the problems that eventually motivated Promises.

The Callback Pattern

A function that does async work takes a callback as a parameter. When the work is done, it calls the callback with the result:

function loadData(callback) {
  // Simulate async work with setTimeout
  setTimeout(() => {
    const data = { name: "Olena", age: 35 };
    callback(data);
  }, 100);
}

console.log("Loading...");
loadData(result => {
  console.log("Loaded:", result);
});
console.log("Loading initiated, waiting...");

loadData doesn't return the data directly. It can't — the data isn't available yet. Instead, it accepts a callback function and promises (informally) to call it later, when the data is ready. The caller passes a function that handles the eventual result.

This is the basic pattern. Notice that loadData returns immediately, before any data is loaded. The synchronous code continues. The callback runs later, asynchronously, when the timer fires.

Interactive animation: The callback is handed over and loadUser returns at once, so "after the call" logs before the user arrives.

The Node Convention: Error-First Callbacks

When Node.js was created, it standardized a specific callback shape called error-first callbacks (also called "Node-style callbacks"). The convention: the first argument to the callback is always an error (or null if no error). The actual result, if any, comes after.

function loadDataWithErrorHandling(shouldFail, callback) {
  setTimeout(() => {
    if (shouldFail) {
      callback(new Error("Failed to load"));
    } else {
      callback(null, { name: "Olena", age: 35 });
    }
  }, 50);
}

loadDataWithErrorHandling(false, (err, data) => {
  if (err) {
    console.log("Error:", err.message);
    return;
  }
  console.log("Success:", data);
});

loadDataWithErrorHandling(true, (err, data) => {
  if (err) {
    console.log("Error:", err.message);
    return;
  }
  console.log("Success:", data);
});

Two calls, two outcomes. The pattern is consistent: check the first argument for an error first. If there's an error, handle it and return. If not, use the data.

This convention solved a real problem in early Node code: how do you signal failure from an async operation? Throwing an exception doesn't work — the throw happens asynchronously, after the calling function has long since returned. The error-first callback became the standard way to propagate errors through async code.

You'll see this convention everywhere in older Node code — fs.readFile, db.query, request.get. Every async operation took an error-first callback. Most of those APIs now also have Promise versions, but the pattern still appears.

Interactive animation: Error first, data second - an fs.readFile callback checks err, returns early, and only then parses the data.

Callback Hell

The callback pattern works for individual async operations. The problem is what happens when you chain several:

function step1(callback) {
  setTimeout(() => callback(null, "result1"), 50);
}

function step2(input, callback) {
  setTimeout(() => callback(null, input + " + result2"), 50);
}

function step3(input, callback) {
  setTimeout(() => callback(null, input + " + result3"), 50);
}

// Chaining three async operations
step1((err, r1) => {
  if (err) return console.log("Error:", err);
  step2(r1, (err, r2) => {
    if (err) return console.log("Error:", err);
    step3(r2, (err, r3) => {
      if (err) return console.log("Error:", err);
      console.log("Final:", r3);
    });
  });
});

Three async operations, three levels of nesting. Each level adds an error check. The code drifts further and further to the right as you nest deeper. This is callback hell (also called "the pyramid of doom"), and it was the bane of early Node.js code.

graph TD
    A[step1] --> B[step2]
    B --> C[step3]
    C --> D[step4]
    D --> E[final result]

    style A fill:#dc2626,color:#fff
    style B fill:#dc2626,color:#fff
    style C fill:#dc2626,color:#fff
    style D fill:#dc2626,color:#fff
This diagram shows the structure of callback hell. Each async operation takes a callback that calls the next async operation, which takes another callback, and so on. Each level adds indentation, error handling, and conceptual complexity. The code reads as a pyramid drifting to the right, hard to follow and hard to maintain.

The problems with callback hell are real and serious:

Indentation grows with depth. Every async step adds another level of nesting. Code with five sequential async operations is unreadable.

Error handling repeats at every level. Each callback has to check for errors and forward them. This is tedious and error-prone.

Control flow is inverted. You're not writing "do A, then B, then C." You're writing "do A, and when A finishes, do B, and when B finishes, do C." The reader has to mentally untangle this.

Composing multiple async operations is hard. Want to run three operations in parallel and combine their results? With callbacks, you have to manually track which ones have completed.

These problems were what motivated Promises (Topic 9.4) and ultimately async/await (Topic 9.6). Each generation of async handling has reduced the complexity, but the callback pattern is what they're all built on.

Inversion of Control

There's a subtle problem with callbacks beyond the syntax issue: you're giving control of when your code runs to someone else's function.

function processOrder(orderData, callback) {
  // some processing
  setTimeout(() => callback(null, "done"), 50);
}

processOrder({ amount: 100 }, (err, result) => {
  console.log("Callback called with:", result);
});

When you pass a callback to processOrder, you're trusting processOrder to:

A buggy or malicious processOrder could call your callback twice, or never call it, or call it with unexpected arguments. There's no enforcement. You're trusting it.

Promises solve this by inverting back: the caller controls when their callback runs (via .then), not the async function. We'll see this in Topic 9.4.

When to Still Use Callbacks

Even with Promises and async/await, callbacks remain the right choice in several cases:

Event handlers. addEventListener("click", handler) is the canonical example. Events fire many times, not once, so they don't fit the Promise model well.

Streams and observables. When you're consuming a sequence of values over time, callbacks (or observer patterns built on callbacks) are the right tool.

APIs that are inherently callback-based. Some older Node APIs and many third-party libraries still use callbacks. Wrapping them in Promises is possible (using util.promisify), but sometimes direct callback use is clearer.

Performance-sensitive code. Promises have small overhead. In hot inner loops, raw callbacks can be faster. This rarely matters, but it's worth knowing.

For most other cases — sequential async operations, error handling, composing multiple operations — Promises and async/await are better tools.

Interactive animation: Where each pattern belongs - callbacks for events that fire again and again, promises for work that settles once.

Common Pitfalls

Warning: The callback traps.

Forgetting to call the callback on every code path. If your async function has branches, every one must call the callback exactly once. Forgetting on one path leaves the caller hanging forever.

Calling the callback twice. A different version of the same bug. If both a success and error path call the callback, the caller might handle the same result twice.

Calling the callback synchronously sometimes and asynchronously other times. This is the "Zalgo" problem — code that's sometimes async, sometimes sync, surprising the caller. Always be consistently async (use setTimeout(callback, 0) or queueMicrotask(callback) if you have an immediate result).

Forgetting error-first convention. If you write a callback-based API, follow the Node convention. Otherwise users have to remember a custom error-handling pattern for every API.

Why This Matters in Interviews

Interview: Callback questions are foundational but rarely asked directly at senior levels. They appear as "what's the problem with callbacks" or "why were Promises introduced." The expected answer covers callback hell, error-handling repetition, and inversion of control.

Implementation questions sometimes still involve callbacks — implement a callback-style version of something, or convert a callback API to a Promise API. Both require fluency with the pattern.

Topic 9.11 — Interview Lens: The Event Loop Question — and Topic 9.12 — Promise and async/await Patterns — cover the deeper interview treatment.

Try It

Exercise: Take this callback hell code and identify each problem:

getUser(userId, function(err, user) {
  if (err) { handleError(err); return; }
  getOrders(user.id, function(err, orders) {
    if (err) { handleError(err); return; }
    getProducts(orders, function(err, products) {
      if (err) { handleError(err); return; }
      calculateTotal(products, function(err, total) {
        if (err) { handleError(err); return; }
        console.log("Total:", total);
      });
    });
  });
});

Identify: (1) how many levels of nesting, (2) how many times error handling is repeated, (3) how would you refactor this with Promises (you don't need to write the code yet — just describe the shape it would take).

By Topic 9.4, you'll know how to actually write the Promise version. Before then, just appreciate the problem callbacks introduce when chained.

Back to JavaScript Basic to Advance