Callbacks

Understand the callback pattern for handling asynchronous results, recognize callback nesting and 'callback hell', and learn conventional error-handling with callbacks.

Before promises existed, callbacks were the only tool JavaScript had for saying 'run this function once this asynchronous thing finishes.' They are still everywhere in the language and in Node.js APIs, and understanding them is essential both for reading older/existing code and for appreciating exactly what problem promises were designed to solve.

The callback pattern

A callback is simply a function passed as an argument to another function, to be 'called back' (invoked) later — either synchronously (like in Array.prototype.map) or asynchronously (like in setTimeout, or a network request). In asynchronous contexts, a callback is how you receive a result you can't have immediately, because the value doesn't exist yet when the outer function is called.

The pattern is powerful precisely because functions are first-class values in JavaScript: they can be stored in variables, passed as arguments, and invoked whenever and wherever needed — including by code that ran long after the original function call returned.

Callback nesting and 'callback hell'

Real programs often need several asynchronous steps to happen in sequence, where each step depends on the result of the one before it. With plain callbacks, this means nesting a new callback inside the previous one's callback, again and again — producing code that grows to the right with every added step, commonly nicknamed 'callback hell' or the 'pyramid of doom.'

Beyond just looking ugly, deeply nested callbacks are genuinely hard to reason about: error handling has to be repeated at every level, it's easy to forget a level, and the logical order of operations gets harder to follow the deeper the nesting goes. This exact pain point is what directly motivated the design of promises, covered in the next lesson.

Error handling with callbacks

Because a callback-based asynchronous function can't use a normal try/catch around 'the async part' (the calling code has already moved on by the time the callback runs), JavaScript and Node.js conventionally use the 'error-first callback' pattern: the callback's first parameter is reserved for an error (or null if there was none), and the second parameter (and beyond) hold the actual result.

This convention means every callback must explicitly check its first argument before trusting the rest, and every asynchronous function that can fail must remember to pass an error object into that first slot when something goes wrong — there's no language-level enforcement, only convention, which is one more reason this pattern is error-prone at scale.

Code Examples

greetUser doesn't return the greeting directly — it accepts a callback function and calls it with the result. This is a synchronous callback (it runs immediately, no waiting involved), but it demonstrates the basic shape: pass a function in, have it invoked with a value later. Expected result: Hello, Maria!

callback("Hello, " + name + "!") is where control is handed back to the function the caller provided.

This simulates a real asynchronous operation (like a database or network call) using setTimeout as a stand-in for the delay. It follows the error-first callback convention: the callback always checks err first before trusting user. Expected result: Got user: User 3 If fetchUser(-1, ...) were called instead, the callback would print 'Failed: Invalid user id' because err would be a truthy Error object.

callback(new Error("Invalid user id"), null) and callback(null, user) both follow the (error, result) parameter order that Node.js-style APIs use everywhere.

Each step depends on the previous step's result, so each new asynchronous call must be nested inside the previous callback. Notice how the code marches to the right with every added step, and the same error-checking boilerplate ('if (err) return ...') has to be repeated at every single level.

The nesting depth grows by one level for every additional asynchronous step in the sequence — this is the literal shape of 'callback hell.'

Common mistake: A common beginner mistake is forgetting to return after handling an error inside a nested callback, which lets execution continue and try to use data (like orders[0]) that doesn't actually exist, causing a confusing crash.

function greetUser(name, callback) {
  callback("Hello, " + name + "!");
}

greetUser("Maria", function(message) {
  console.log(message);
});
function fetchUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid user id"), null);
      return;
    }
    callback(null, { id: id, name: "User " + id });
  }, 500);
}

fetchUser(3, (err, user) => {
  if (err) {
    console.log("Failed:", err.message);
    return;
  }
  console.log("Got user:", user.name);
});
getUser(1, (err, user) => {
  if (err) return console.log("Error getting user");
  getOrders(user.id, (err, orders) => {
    if (err) return console.log("Error getting orders");
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return console.log("Error getting details");
      console.log("Details:", details);
    });
  });
});

Important Rules

A callback is a function passed to another function, to be invoked later — synchronously or asynchronously.

Asynchronous callbacks are how a function 'returns' a value it doesn't have yet, without blocking the thread.

Chaining several dependent asynchronous steps with callbacks forces nesting one callback inside another — 'callback hell.'

The error-first callback convention places an error (or null) as the callback's first argument, and the actual result as later arguments.

Callback-based error handling relies entirely on the developer remembering to check and propagate the error argument at every level — there is no automatic propagation.

Where this shows up

Callbacks are still the foundation of DOM event handling (addEventListener("click", callback)), array iteration methods (map, filter, forEach), and many older Node.js core APIs (like fs.readFile). Even though promises and async/await are preferred for sequencing asynchronous operations today, recognizing and reading the callback pattern remains essential for working with existing libraries and the DOM event system.

function loadData(cb) { fetchFromServer((err, data) => { if (err) console.log("error"); cb(data.value); }); } function loadData(cb) { fetchFromServer((err, data) => { if (err) { console.log("error"); return; } cb(data.value); }); }

Without a return after handling the error, execution falls through and tries to read data.value even though data may be null or undefined when an error occurred, crashing the program instead of stopping cleanly.

[Multiple Choice] In the Node.js error-first callback convention, what does the FIRST parameter of the callback represent? A. The successful result of the operation B. An error object (or null if there was no error) C. The original arguments passed to the outer function D. A boolean indicating whether the callback ran synchronously

B. An error object (or null if there was no error) — By convention, the first parameter is reserved for an error (or null on success), and the actual result data comes after it — callers must check this first before trusting later parameters.

Write a function delayedSquare(n, callback) that uses setTimeout with a 300ms delay to call callback(null, n * n). Then call it with 5 and log the result inside the callback, handling a possible error first.

function delayedSquare(n, callback) { setTimeout(() => { callback(null, n * n); }, 300); } delayedSquare(5, (err, result) => { if (err) { console.log("Error:", err); return; } console.log("Square:", result); });

Follow the error-first convention even though this particular function will never actually produce an error.