Callbacks and Callback Hell
The Core Problem#
Your Node.js server takes an order, and the food needs one second to cook. If JavaScript stood still for that second, every other customer would be stuck behind that single order. JavaScript has only one thread, so it cannot afford to wait. Instead it uses callbacks: "do this later, when the slow work is done."
In this post you will learn how JavaScript avoids waiting, how to write your own callbacks, and why callbacks turn into a mess when each step depends on the one before it.
By the end you will be able to:
- Predict the output of code that mixes normal lines and timers
- Write a function that accepts a callback
- Read a callback pyramid and explain what is wrong with it
Mental Model & Intuition#
Picture a chai stall with one person behind the counter.
- A customer orders tea. The person puts the kettle on and does not stand there watching it.
- They take the next order while the kettle heats.
- When the kettle whistles, they pour the tea.
That is exactly how JavaScript works.
| Chai stall | JavaScript |
|---|---|
| One person behind the counter | The single thread that runs your code |
| The kettle | A timer, file read or API call running in the background |
| The whistle | The background work finishes |
| "What to do when it whistles" | The callback function |
| Checking for whistles when free | The event loop |
A callback is simply a function you pass into another function, so that it can be called later.
The one rule to remember: a callback never interrupts code that is running. When the background work finishes, the callback waits in a queue. The event loop runs it only when the current code has completely finished.
Read this and predict the output before you run it.
console.log("1. Take order");
setTimeout(() => {
console.log("3. Tea is ready");
}, 0);
console.log("2. Take next order");
Most beginners expect 1, 3, 2 because the timer is set to 0 milliseconds. The real output is 1, 2, 3. Even with 0 ms, the callback waits until the current code is done.
Step-by-Step Walkthrough#
Here is what happens inside the example above, one step at a time.
| Step | State | Why |
|---|---|---|
| 1 | "1. Take order" is printed |
Normal lines run immediately, top to bottom |
| 2 | setTimeout hands the timer to the background and moves on |
JavaScript never waits for a timer |
| 3 | "2. Take next order" is printed |
The thread is free, so it keeps going |
| 4 | All code has finished, so the stack is empty | The callback can only run now |
| 5 | The event loop runs the callback and prints "3. Tea is ready" |
Callbacks run only when the thread is free |
Code Implementation#
1. What blocking looks like#
If your own code keeps the thread busy, callbacks cannot run, even if their time has come.
const start = Date.now();
setTimeout(() => {
console.log("Timer fired after", Date.now() - start, "ms");
}, 100);
while (Date.now() - start < 2000) {
// busy loop: nothing else can run
}
Output:
Timer fired after 2000 ms
The timer asked for 100 ms, but the thread was busy for 2000 ms. This is why you should never run long loops or heavy work directly in a server.
2. Your first callback#
function orderFood(item, onReady) {
console.log(`Order placed: ${item}`);
setTimeout(() => {
onReady(`${item} is ready`);
}, 1000);
}
orderFood("Biryani", (message) => {
console.log(message);
});
console.log("Waiting, but not blocked");
Output:
Order placed: Biryani
Waiting, but not blocked
Biryani is ready
orderFood does not return the food. It promises to call onReady later, and the line after it keeps running in the meantime.
Not every callback is async. [1, 2, 3].map((n) => n * 2) also takes a callback, but it runs right away. Callbacks are just functions passed as arguments. Async is what we do with them.
3. Error-first callbacks#
Node.js has a convention: a callback receives the error first, and the result second. If there is no error, the first argument is null.
function login(name, callback) {
setTimeout(() => {
if (!name) return callback(new Error("Name is required"));
callback(null, { id: 1, name });
}, 300);
}
login("Sai", (err, user) => {
if (err) return console.log("Login failed:", err.message);
console.log("Logged in as", user.name);
});
Built-in Node functions such as fs.readFile follow the same pattern, so you will see it again in later sessions.
4. Ticket booking: where callbacks go wrong#
Booking a ticket has four steps, and each needs the result of the previous one: log in, pick a seat, pay, and send the ticket. First, the helper functions:
function login(name, callback) {
setTimeout(() => {
if (!name) return callback(new Error("Name is required"));
callback(null, { id: 1, name });
}, 300);
}
function pickSeat(user, callback) {
setTimeout(() => {
callback(null, { seat: "C7", price: 350 });
}, 300);
}
function pay(seat, walletBalance, callback) {
setTimeout(() => {
if (walletBalance < seat.price) {
return callback(new Error("Insufficient wallet balance"));
}
callback(null, { receiptId: "R-1001", amount: seat.price });
}, 300);
}
function sendTicket(receipt, callback) {
setTimeout(() => {
callback(null, `Ticket sent for receipt ${receipt.receiptId}`);
}, 300);
}
const walletBalance = 200;
Now chain them, so that each step runs only after the previous one succeeds:
With a wallet balance of 200 and a seat price of 350, both versions print:
Logged in as Sai
Seat picked: C7
Payment failed: Insufficient wallet balance
Change walletBalance to 500 and you get the full success path:
Logged in as Sai
Seat picked: C7
Paid: 350
Ticket sent for receipt R-1001
The pyramid version is callback hell: the code drifts to the right with every step, and every level needs its own error check. The named function version is flatter, but you now read the flow from bottom to top. Neither version is pleasant, and that is exactly why promises exist.
Edge Cases & Gotchas#
Pass the function, do not call it. setTimeout(greet, 1000) waits one second and then calls greet. setTimeout(greet(), 1000) calls greet immediately and passes its result (usually undefined) to the timer, so Node throws a TypeError saying the callback must be a function.
Always return after calling the callback with an error. Without return, the function continues and may call the callback a second time, so your success code runs even though an error happened. That is why every example above uses return callback(...).
try/catch cannot catch errors thrown inside an async callback. By the time the callback runs, the try block has already finished.
try {
setTimeout(() => {
throw new Error("Boom");
}, 100);
} catch (err) {
console.log("Caught:", err.message); // never runs
}
Node crashes with an uncaught error instead. This is why errors are passed to callbacks as the first argument, instead of being thrown.
setTimeout(fn, 0) is not instant. It means "as soon as the current code is finished", not "right now".
Pro Tip / Performance Insight#
A good rule: the moment you are about to indent a third level of callbacks, stop. Either give each callback a name, or move to promises, which let you write the same flow as a flat chain.
Node can even convert an error-first callback function into a promise-returning one for you:
const { promisify } = require("util");
const loginAsync = promisify(login);
loginAsync("Sai").then((user) => console.log(user));
loginAsync("").catch((err) => console.log("Failed:", err.message));
Output:
{ id: 1, name: 'Sai' }
Failed: Name is required
Next Steps & Practice#
Try these on your own before moving on:
- Write
orderFood(item, onReady)so that it calls back after 2 seconds, then call it for three items one after another, with each starting only after the previous one is ready. Notice how the code shifts to the right. - Run the booking example with a
walletBalanceof 500, and then makeloginfail by passing an empty name. Which lines stop printing? - Predict the output of this code, then run it:
console.log("A");
setTimeout(() => console.log("B"), 0);
setTimeout(() => console.log("C"), 100);
console.log("D");
Read next: Promises and Chaining, where the same ticket booking flow becomes flat and readable.