Callbacks & the Pyramid of Doom
Advanced · 17 min read · ▶ live playground · ✦ checkpoint
A callback can speak after the script that created it has already finished. That is asynchronous code, code that starts now but finishes later, and callbacks were JavaScript's first way to say, "run this when the moment arrives." They also created the Pyramid of Doom, nested callback code that keeps marching right until the real story is hard to see.
A Callback Can Arrive Later
The callback is still the recipe card from lesson 5.5: you hand a function to someone else so they can use it at the right moment. With a timer, the "someone else" is the runtime. It holds the card while your stack keeps moving.
console.log("order placed");
setTimeout(() => {
console.log("fries ready");
}, 10);
setTimeout(() => {
console.log("drink ready");
}, 20);
console.log("receipt printed");→ order placed, receipt printed, fries ready, then drink ready.
The timer callbacks don't interrupt the script. They wait for the stack to empty, then the event loop gives each callback a turn. The delays are different on purpose: timers are promises of scheduling, not guarantees of exact timing, so distinct delays keep the order clear.
Events Are Named Later Moments
An event is a named signal that says "this happened." An event listener is a callback registered for that signal. In a browser, clicks and key presses are events; here, you can see the same pattern with a tiny event list built from plain JavaScript.
function createEventList() {
const listeners = new Map();
return {
on(eventName, listener) {
const existing = listeners.get(eventName) ?? [];
listeners.set(eventName, [...existing, listener]);
},
emit(eventName, payload) {
const existing = listeners.get(eventName) ?? [];
for (const listener of existing) {
listener(payload);
}
},
};
}
const scoreboard = createEventList();
scoreboard.on("roundEnd", result => {
console.log(`${result.player} scored ${result.score}.`);
});
scoreboard.on("roundEnd", result => {
console.log(`mood: ${result.mood}`);
});
scoreboard.emit("roundEnd", {
player: "Mina",
score: 24,
mood: "relieved",
});→ Mina scored 24., then mood: relieved.
on stores the recipe cards. emit rings the named signal and calls every listener registered for it. That is the shape underneath real page events later in the course: register now, run later when the moment happens.
The Node Convention Puts Errors First
Older Node APIs often use an error-first callback, a callback whose first argument is an error when work failed and null when work succeeded. The second argument carries the success value.
function readSavedScore(player, delay, done) {
setTimeout(() => {
if (player === "") {
done(new Error("Player name is required"));
return;
}
done(null, { player, score: 31 });
}, delay);
}
function printScore(error, record) {
if (error) {
console.log(`Could not load score: ${error.message}`);
return;
}
console.log(`${record.player}: ${record.score}`);
}
readSavedScore("Rae", 10, printScore);
readSavedScore("", 20, printScore);→ Rae: 31, then Could not load score: Player name is required.
That return after done(new Error(...)) matters. Without it, the success path would keep running too. In callback APIs, success and failure are often just branches inside a function, so you guard the failure path and leave immediately.
Try it — wire a tiny async scoreboard
Predict the order before you run it. The first two logs happen during setup. The score arrives later through a callback, then the second timer fires.
At the › prompt, register a second listener and emit the event again:
scoreboard.on("scoreLoaded", record => console.log(`again: ${record.player}`));
scoreboard.emit("scoreLoaded", { player: "Mina", score: 37 });You will see Mina: 37, then again: Mina, because the event list calls both callbacks in the order you registered them.
The Pyramid Appears
Callback hell is code where each later async step must live inside the previous callback. The shape is not the real bug; the shape is the warning light. Every extra step adds another level of indentation, another error branch, and another place where control lives outside your current function.
function choosePlayer(done) {
setTimeout(() => {
done(null, "Mina");
}, 10);
}
function loadScore(player, done) {
setTimeout(() => {
done(null, { player, score: 42 });
}, 20);
}
function saveBadge(record, done) {
setTimeout(() => {
done(null, `${record.player} earned the Gold badge.`);
}, 30);
}
choosePlayer((pickError, player) => {
if (pickError) {
console.log(`pick failed: ${pickError.message}`);
return;
}
loadScore(player, (scoreError, record) => {
if (scoreError) {
console.log(`score failed: ${scoreError.message}`);
return;
}
saveBadge(record, (badgeError, badgeMessage) => {
if (badgeError) {
console.log(`badge failed: ${badgeError.message}`);
return;
}
console.log(badgeMessage);
});
});
});→ Mina earned the Gold badge.
The program works. That is why the trap lasted so long. But the main path is buried three callbacks deep, and every error check repeats the same guard shape.
function saveScore(score, done) {
setTimeout(() => {
done(null, `saved ${score}`);
done(null, `saved ${score} again`);
}, 10);
}
saveScore(41, (error, message) => {
if (error) {
console.log(`error: ${error.message}`);
return;
}
console.log(message);
});→ saved 41, then saved 41 again.
Callbacks are still everywhere: timers, event listeners, array methods, and older Node APIs all use them. But for multi-step async work, JavaScript now gives you a better shape. Next, promises turn a future result into a value you can chain without building the pyramid.
Checkpoint
Answer all three to mark this lesson complete