Callbacks and the Callback Pattern

8 min read

The original way JavaScript handled async work was the callback — pass a function as an argument, and the runtime calls it back when the work is done. Callbacks are still everywhere in older Node.js code and a few test libraries, and understanding them is what lets you make sense of the patterns that replaced them. This lesson covers callback syntax, the Node.js error-first convention, and the famous "callback hell" that motivated promises and async/await.

What a callback actually is

A callback is just a function you pass to another function, with the agreement that it'll be called later. Nothing magical — the same function-as-a-value idea you saw with array methods, applied to async work.

You've already met one:

setTimeout(() => console.log("hi"), 1000);

The arrow function () => console.log("hi") is the callback. setTimeout accepts a callback and a delay. After the delay, the runtime calls back to your function. You wrote the function; you handed it over; the runtime calls back into it.

A real callback — Node.js fs.readFile

Node.js's classic API uses callbacks heavily. The async version of readFile doesn't return the contents — it accepts a callback that gets the result when it's ready.

const fs = require("node:fs");
 
fs.readFile("config.json", "utf-8", (error, data) => {
  if (error) {
    console.error("Read failed:", error.message);
    return;
  }
  console.log("File loaded, size:", data.length);
});
 
console.log("readFile dispatched, moving on...");

Output (assuming the file exists):

readFile dispatched, moving on...
File loaded, size: 2304

Notice the line after readFile runs first. Same async behaviour as setTimeout — the file work happens in the background, and the callback fires when it finishes.

Error-first convention

Node.js callbacks follow a consistent shape: the first parameter is always the error. If something went wrong, that argument is filled with an Error object; if everything succeeded, it's null. The actual result lives in the second (and later) parameters.

fs.readFile("missing.json", "utf-8", (error, data) => {
  if (error) {
    console.error("Read failed:", error.code);  // e.g., "ENOENT"
    return;
  }
  console.log(data);
});

The pattern reads as: "check error first; bail if it's set; only then use the result." Always handle the error. Skipping the check means a real failure runs as if everything worked, with data undefined and a confusing follow-on bug.

Visualising the flow

That's the callback model in one picture. You hand a function to the runtime; the runtime calls it later, with the result or with an error. Done.

A real test fixture loader

Combining what you know — read a JSON file, parse it, log a count.

const fs = require("node:fs");
 
fs.readFile("users.json", "utf-8", (error, text) => {
  if (error) {
    console.error("Could not read users.json:", error.message);
    return;
  }
  const users = JSON.parse(text);
  console.log(`Loaded ${users.length} users`);
});

Output:

Loaded 5 users

That's a complete async fixture loader in nine lines. Now what happens when the next step is also async?

Callback hell

Test code rarely does one async thing. A typical scenario: read a config, fetch the test users, save the filtered ones, then notify a dashboard. Each step is async. With callbacks, every "next step" lives inside the previous callback — and the indentation grows:

const fs = require("node:fs");
 
fs.readFile("config.json", "utf-8", (cfgErr, cfgText) => {
  if (cfgErr) return console.error("config:", cfgErr);
  const config = JSON.parse(cfgText);
 
  fs.readFile(config.usersPath, "utf-8", (usrErr, usrText) => {
    if (usrErr) return console.error("users:", usrErr);
    const users = JSON.parse(usrText);
    const admins = users.filter(u => u.role === "admin");
 
    fs.writeFile("admins.json", JSON.stringify(admins, null, 2), (wrErr) => {
      if (wrErr) return console.error("write:", wrErr);
 
      fs.appendFile("audit.log", `Wrote ${admins.length} admins\n`, (logErr) => {
        if (logErr) return console.error("log:", logErr);
        console.log("All done");
      });
    });
  });
});

That's "callback hell," sometimes called the pyramid of doom. Each step nests inside the previous callback. Error handling is repeated four times. The actual work — read, filter, write, log — is buried under braces and indentation. Adding a fifth step pushes the indentation off the right edge of the screen.

Three real problems:

  • Hard to read. The structure obscures the sequence of operations.
  • Hard to maintain. Adding a new step means re-indenting everything below it.
  • Error handling is repetitive. Every callback needs the same if (err) return boilerplate.

This is exactly the pain that motivated the next two lessons. Promises flatten the pyramid into a chain. async/await makes the chain look like ordinary synchronous code. Both were invented because callbacks at this scale are unmanageable.

When you'll still see callbacks

You won't write callback-heavy code in modern test suites — but you'll read it. Three places callbacks survive:

  • Older Node.js APIs. Pre-promise APIs (fs.readFile, dns.lookup, crypto.pbkdf2) all expect callbacks. There are now fs.promises versions, but legacy code uses the callback variants.
  • Event handlers. button.addEventListener("click", () => ...) is still a callback — and that's fine. Single-event callbacks aren't where the hell starts.
  • Older test libraries. Some Mocha/Jest patterns still use a done callback for async tests in legacy codebases.

The pattern itself isn't bad. It's the chaining of callbacks that turns ugly fast.

⚠️ Common mistakes

  • Skipping the error check. (error, data) => { console.log(data); } ignores the error parameter. If the operation failed, data is undefined and the next line crashes with a confusing message. Always handle the error first.
  • Returning a value from a callback expecting it to come back. const result = fs.readFile(...) doesn't work — readFile returns immediately (with undefined). The result only exists inside the callback. This is a foundational misunderstanding worth getting clear early.
  • Calling the callback twice. A poorly written async helper can fire its callback once on success and once on error. Each callback should be called exactly once. Promises solve this by design — they can only resolve or reject once.

🎯 Practice task

Build a callback chain you can feel hurt. 15-20 minutes.

  1. In your js-for-qa folder, create users.json with 3 users ({ name, role } — at least one admin).

  2. Create cb-demo.js:

    const fs = require("node:fs");
     
    fs.readFile("users.json", "utf-8", (err, text) => {
      if (err) return console.error(err);
      const users = JSON.parse(text);
      const admins = users.filter(u => u.role === "admin");
     
      fs.writeFile("admins.json", JSON.stringify(admins, null, 2), (err2) => {
        if (err2) return console.error(err2);
        console.log(`Wrote ${admins.length} admins`);
      });
    });
  3. Run with node cb-demo.js. Confirm admins.json is created.

  4. Add a third step: after writing admins.json, append a line to audit.log using fs.appendFile. Note how the nesting grows.

  5. Add a fourth step: read audit.log back and console.log its contents. The pyramid is now four levels deep.

  6. Stretch: without changing the behaviour, count how many times the phrase if (err...) appears in your file. That's the callback tax — repetitive boilerplate that promises and async/await eliminate.

The next lesson introduces promises — the modern way to express the same async sequences without the pyramid.

// tip to track lessons you complete and pick up where you left off across devices.