A callback is simply a function that we pass as an argument to another function, and that function calls it back at some point. That’s it. The name literally means “call me back when you’re done.”
Synchronous Callbacks
We already use callbacks all the time without realizing it. Array methods like forEach, map, and filter all take callbacks.
const numbers = [1, 2, 3];
numbers.forEach(function(num) {
console.log(num); // 1, 2, 3
});
const doubled = numbers.map(num => num * 2); // [2, 4, 6]
These are synchronous callbacks — they run immediately, one after another, in the same order.
Asynchronous Callbacks
The real power of callbacks comes with async operations. When we need to do something that takes time (like a timer, an API call, or reading a file), we pass a callback that runs after the operation is done.
console.log("Start");
setTimeout(function() {
console.log("Timer done!"); // runs after 2 seconds
}, 2000);
console.log("End");
// Output: Start, End, Timer done!
The callback inside setTimeout doesn’t block the rest of our code. JavaScript moves on to the next line and comes back to run the callback when the timer finishes.
The problem: Callback Hell
Now imagine we need to do multiple async things in sequence — one after another. With callbacks, each next step has to be nested inside the previous one.
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetails(orders[0].id, function(details) {
getShippingInfo(details.trackingId, function(shipping) {
console.log(shipping.status);
// good luck reading this...
});
});
});
});
This is called Callback Hell or the Pyramid of Doom. Notice how the code keeps moving to the right with each nested callback? It becomes:
- Hard to read
- Hard to debug
- Hard to handle errors (each level needs its own error handling)
- Hard to maintain
Here’s a more visual example with setTimeout:
setTimeout(function() {
console.log("Step 1 done");
setTimeout(function() {
console.log("Step 2 done");
setTimeout(function() {
console.log("Step 3 done");
setTimeout(function() {
console.log("Step 4 done");
// we're 4 levels deep already...
}, 1000);
}, 1000);
}, 1000);
}, 1000);
Error handling is messy too
With callbacks, there’s no standard way to handle errors. The common convention (from Node.js) is “error-first callbacks” — the first argument is always an error.
readFile("data.json", function(error, data) {
if (error) {
console.log("Failed to read file:", error);
return;
}
console.log(data);
});
But when we nest these, we need to check for errors at every single level. It gets painful fast.
Why Promises were invented
Callbacks work fine for simple one-off async operations. But for anything with multiple sequential steps, they create deeply nested, hard-to-read code. This is exactly why Promises were introduced in ES6 — they let us flatten the nesting and chain async operations in a clean, readable way. We’ll cover those next.
In simple language, a callback is just a function you hand to someone else and say “call this when you’re done.” They’re fundamental to how JavaScript handles async operations, but when you stack them too deep, the code becomes a nightmare. That’s when you reach for Promises.