Callbacks in JavaScript: Why They Exist

JavaScript treats functions differently than most languages. In a lot of places, functions are special reserved syntax, called in specific ways, living apart from regular values. In JavaScript, a function is just a value. You can store it in a variable, put it in an array, pass it as an argument, return it from another function. It behaves like any other piece of data.
That one characteristic is what makes callbacks possible. And callbacks are what make JavaScript's entire async model work.
Functions as Values
Before callbacks make sense, the underlying idea needs to land: functions in JavaScript are first-class values.
function greet(name) {
return `Hello, ${name}!`;
}
const sayHello = greet;
console.log(sayHello("Amara")); // Hello, Amara!
sayHello isn't a copy of greet. It's another reference to the same function. You can hand a function to something else just like you'd hand over a number or a string.
That means functions can be passed as arguments:
function runTwice(fn) {
fn();
fn();
}
function wave() {
console.log("๐");
}
runTwice(wave);
// ๐
// ๐
wave gets passed into runTwice as an argument. Inside runTwice, it's just called fn but it's the same function. runTwice doesn't know or care what fn does. It just calls it.
That's a callback. A function passed into another function to be called at some point.
What a Callback Actually Is
A callback is any function you pass as an argument with the expectation that it will be called later either after some work is done, when an event happens, or at a specific point in another function's execution.
function processNumber(num, callback) {
const result = num * 2;
callback(result);
}
processNumber(5, function(result) {
console.log("Result:", result); // Result: 10
});
processNumber does its work doubles the number then hands the result to the callback. The caller decides what happens with the result. The function doing the work doesn't need to know.
This separation is the whole point. The function doing the processing and the function handling the result are decoupled. You can pass a different callback and get completely different behavior without touching processNumber at all.
Why Callbacks Exist: The Async Problem
For synchronous code math, string operations, working with data already in memory callbacks are a nice pattern but not strictly necessary. You could just return a value.
The moment you deal with something that takes time, returning a value stops working.
// This doesn't work the way it looks like it should
function getData() {
let result;
setTimeout(function() {
result = "data from server";
}, 2000);
return result; // returns undefined โ the timeout hasn't run yet
}
const data = getData();
console.log(data); // undefined
getData returns before the timeout fires. The value isn't there yet. JavaScript doesn't wait it moves on, and result is still undefined by the time you try to use it.
Callbacks solve this by flipping the model. Instead of asking for a value back, you hand over a function to be called when the value is ready:
function getData(callback) {
setTimeout(function() {
const result = "data from server";
callback(result);
}, 2000);
}
getData(function(data) {
console.log("Got it:", data); // Got it: data from server
});
Now nothing tries to grab a value before it exists. You describe what to do with the data, and that description runs at exactly the right moment.
WITHOUT CALLBACK: WITH CALLBACK:
getData() getData(handleData)
โ โ
โ returns immediately โ registers callback
โ โ
undefined โ too early โ ... time passes ...
โ
โ
handleData("data from server")
โ
โ
runs at the right time โ
Callbacks in Common Scenarios
Event Listeners
The most visible callbacks in frontend JavaScript are event listeners. You don't call the handler yourself you hand it to the browser and let it call it when the event fires:
document.getElementById("btn").addEventListener("click", function() {
console.log("Button clicked");
});
The second argument is a callback. The browser holds onto it and calls it whenever a click happens could be immediately, could be never. You describe the behavior; the browser decides when.
Array Methods
forEach, map, filter, reduce all of them take callbacks:
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(num) {
return num * 2;
});
console.log(doubled); // [2, 4, 6, 8, 10]
map iterates the array and calls your callback once per element. You define what transformation happens. map handles the iteration. Clean separation of concerns.
const names = ["Amara", "James", "Priya"];
names.forEach(function(name) {
console.log(`Hello, ${name}!`);
});
// Hello, Amara!
// Hello, James!
// Hello, Priya!
Timers
setTimeout and setInterval both take callbacks code to run after a delay or on a repeating schedule:
setTimeout(function() {
console.log("Runs after 3 seconds");
}, 3000);
setInterval(function() {
console.log("Runs every second");
}, 1000);
Neither of these would work with a return value. The timing is the whole point, and timing requires a callback to hold the deferred behavior.
The Nesting Problem
Callbacks work well in isolation. One operation, one callback, clean and readable. The problem appears when async operations depend on each other when you need the result of one before starting the next.
getUserId(function(userId) {
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getInvoice(orders[0].id, function(invoice) {
console.log("Invoice:", invoice);
});
});
});
});
Four async operations, four levels of nesting. The code drifts rightward with every step. Finding where one callback ends and another begins requires careful bracket counting. Adding error handling at each level makes it worse:
getUserId(function(err, userId) {
if (err) return handleError(err);
getUser(userId, function(err, user) {
if (err) return handleError(err);
getOrders(user.id, function(err, orders) {
if (err) return handleError(err);
getInvoice(orders[0].id, function(err, invoice) {
if (err) return handleError(err);
console.log("Invoice:", invoice);
});
});
});
});
NESTED CALLBACK EXECUTION FLOW:
getUserId()
โโโ callback(userId)
โโโ getUser()
โโโ callback(user)
โโโ getOrders()
โโโ callback(orders)
โโโ getInvoice()
โโโ callback(invoice)
โโโ done โ
Every arrow is another level of indentation. Every level is another place for bugs to hide. This is callback hell not a failure of callbacks as a concept, but a consequence of using them for sequential async workflows they weren't designed to handle cleanly.
This problem sequential async steps that need to stay readable is exactly what Promises and async/await were built to solve. But they didn't replace callbacks. They were built on top of the same underlying idea: pass behavior somewhere, let it run when the time is right.
Summary
Callbacks are the simplest expression of a powerful idea: behavior and timing don't have to live in the same place. You write what to do. Something else decides when. For event handling, simple async tasks, and array iteration, callbacks are the right tool and the cleanest one. The nesting problem only appears when you chain them together for sequential work and that specific limitation drove the evolution of JavaScript's entire async story. Understanding callbacks is understanding where that story starts.



