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) returnboilerplate.
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 nowfs.promisesversions, 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
donecallback 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,dataisundefinedand 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 —readFilereturns immediately (withundefined). 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.
-
In your
js-for-qafolder, createusers.jsonwith 3 users ({ name, role }— at least one admin). -
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`); }); }); -
Run with
node cb-demo.js. Confirmadmins.jsonis created. -
Add a third step: after writing
admins.json, append a line toaudit.logusingfs.appendFile. Note how the nesting grows. -
Add a fourth step: read
audit.logback andconsole.logits contents. The pyramid is now four levels deep. -
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.