Callback Functions in JavaScript: How Asynchronous Code Really Works

Before you can understand callbacks, you need to accept one slightly strange idea about JavaScript: functions are values.
Not in a philosophical sense — in a completely literal, practical sense. A function in JavaScript can be stored in a variable, put inside an array, attached to an object as a property, and most importantly, passed as an argument to another function. This is the foundation that callbacks are built on.
Once that clicks, callbacks are not mysterious at all. They are just functions that you hand to another function and say: "run this when you are done."
Functions as Values
In most languages, a function is something you define and call. In JavaScript, it is also something you can pass around. Here is what that looks like:
function greet(name) {
console.log(`Hello, ${name}!`);
}
// You can store a function in a variable
const sayHello = greet;
// And call it through the variable
sayHello("Priya"); // Hello, Priya!
sayHello and greet now point to the same function. Neither call invokes anything — they just reference it. The () is what triggers the call.
This means you can also pass a function to another function:
function runTwice(fn) {
fn();
fn();
}
function sayHi() {
console.log("Hi!");
}
runTwice(sayHi);
// Hi!
// Hi!
runTwice does not know or care what fn does. It just calls it. This is the fundamental pattern behind every callback in JavaScript.
What Is a Callback Function?
A callback is a function passed as an argument to another function, with the expectation that it will be called at some point — either immediately, or later when something happens.
function doSomething(callback) {
console.log("Doing something...");
callback(); // call the function that was passed in
}
function finished() {
console.log("All done!");
}
doSomething(finished);
// Doing something...
// All done!
finished is the callback here. It gets called by doSomething once its own work is complete. Notice that when you pass finished to doSomething, you write finished — not finished(). You are passing the function itself, not calling it. The call happens inside doSomething.
You can also write the callback inline as an anonymous function:
doSomething(function() {
console.log("All done!");
});
Or as an arrow function:
doSomething(() => {
console.log("All done!");
});
All three versions do the same thing. The inline style is common in modern JavaScript because you often write the callback right where you use it, without needing to name it separately.
Callbacks You Already Use
Before getting into asynchronous code, it helps to look at callbacks in contexts you have almost certainly used already.
Array.forEach()
const fruits = ["mango", "banana", "guava"];
fruits.forEach(function(fruit) {
console.log(fruit);
});
// mango
// banana
// guava
The function passed to forEach is a callback. forEach calls it once for each element in the array, passing the current element as an argument. You define what to do with each item — forEach handles when to call it.
Array.map()
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(function(n) {
return n * 2;
});
console.log(doubled); // [2, 4, 6, 8]
Same pattern. You pass a callback to map, and it calls your function for each element, collecting the return values into a new array. The callback defines the transformation; map defines the control flow.
Array.filter()
const scores = [45, 78, 91, 33, 60];
const passing = scores.filter(function(score) {
return score >= 50;
});
console.log(passing); // [78, 91, 60]
Again, a callback. You define the condition; filter decides what to keep.
These examples all use callbacks in a synchronous context — the callback runs immediately, right now, in the current flow of execution. But callbacks become truly essential when JavaScript needs to handle things that happen later.
Why Callbacks Exist: Asynchronous Programming
JavaScript is single-threaded. That means it can only do one thing at a time. There is no parallel execution — just one task running, then the next.
This creates a problem. Some operations take time:
Fetching data from a server
Reading a file from disk
Waiting for a user to click something
Setting a timer
If JavaScript just waited for each of these to finish before moving on, the entire program would freeze. A button click would stop the UI from responding for two seconds while an API call completed. That is unacceptable for anything interactive.
JavaScript's solution is to hand off the slow operation and move on, leaving behind a callback that gets called when the operation eventually finishes. The rest of the program keeps running in the meantime.
setTimeout — the simplest async example
console.log("Start");
setTimeout(function() {
console.log("This runs after 2 seconds");
}, 2000);
console.log("End");
Output:
Start
End
This runs after 2 seconds
setTimeout takes a callback and a delay in milliseconds. It registers the callback to be called later and immediately returns — it does not wait. The program continues to the next line (console.log("End")), and only after 2000ms does the callback run.
This is the core idea: the callback is called not when you write it, but when the time comes.
setInterval — repeating callbacks
let count = 0;
const intervalId = setInterval(function() {
count++;
console.log(`Tick ${count}`);
if (count === 3) {
clearInterval(intervalId);
}
}, 1000);
Output:
Tick 1 (after 1 second)
Tick 2 (after 2 seconds)
Tick 3 (after 3 seconds)
The callback runs repeatedly on a schedule. clearInterval stops it.
Passing Functions as Arguments: A Closer Look
Let's slow down and look at exactly what happens when you pass a function as an argument.
function fetchData(url, onSuccess, onError) {
// imagine this actually fetches something
const success = true;
if (success) {
onSuccess({ id: 1, name: "Rahul" });
} else {
onError("Network error");
}
}
function handleSuccess(data) {
console.log("Got data:", data);
}
function handleError(message) {
console.log("Error:", message);
}
fetchData("https://api.example.com/user", handleSuccess, handleError);
// Got data: { id: 1, name: 'Rahul' }
fetchData receives two callback functions: one for success, one for failure. It decides which one to call based on the outcome. The caller defines what should happen in each case; fetchData defines when to trigger them.
This pattern — passing multiple callbacks for different outcomes — was standard before Promises existed, and you still see it in Node.js core APIs and older libraries.
Callbacks with arguments
The calling function can pass arguments to the callback when it calls it. This is how forEach, map, and filter give you the current element:
function repeat(times, callback) {
for (let i = 0; i < times; i++) {
callback(i); // passes the current index to the callback
}
}
repeat(3, function(index) {
console.log(`Iteration ${index}`);
});
// Iteration 0
// Iteration 1
// Iteration 2
The callback does not pull the index from somewhere — repeat hands it over when calling the function. You define what to do with it.
Callback Usage in Common Scenarios
Event listeners
Callbacks are fundamental to browser event handling. When a user does something, you want code to run. You register a callback for that event:
const button = document.querySelector("#submit");
button.addEventListener("click", function(event) {
console.log("Button clicked!", event.target);
});
The callback runs when the user clicks. Not before. Not on a timer. Specifically when that event fires. The browser calls your function and passes an event object with details about what happened.
document.addEventListener("keydown", function(event) {
if (event.key === "Enter") {
console.log("Enter pressed");
}
});
You can register multiple listeners on the same element. Each callback runs independently when the event occurs.
File reading in Node.js
const fs = require("fs");
fs.readFile("data.txt", "utf8", function(error, contents) {
if (error) {
console.log("Could not read file:", error.message);
return;
}
console.log("File contents:", contents);
});
console.log("This runs before the file is read");
fs.readFile starts reading the file and returns immediately. When the file is ready (or an error occurs), it calls your callback with either an error or the file contents. The convention in Node.js is (error, result) — always check the error first.
HTTP requests (the old way)
function getUser(userId, callback) {
const xhr = new XMLHttpRequest();
xhr.open("GET", `https://api.example.com/users/${userId}`);
xhr.onload = function() {
if (xhr.status === 200) {
callback(null, JSON.parse(xhr.responseText));
} else {
callback(new Error(`Request failed: ${xhr.status}`));
}
};
xhr.onerror = function() {
callback(new Error("Network error"));
};
xhr.send();
}
getUser(1, function(error, user) {
if (error) {
console.log("Error:", error.message);
return;
}
console.log("User:", user.name);
});
This is how JavaScript handled HTTP requests before fetch and Promises. The callback-based XHR API is verbose, but it follows the same pattern: start the operation, register a callback, let it run when ready.
The Callback Nesting Problem
Callbacks work cleanly for a single async operation. The trouble starts when you need several operations in sequence, where each one depends on the result of the previous one.
Imagine you need to:
Fetch a user by ID
Use the user's ID to fetch their orders
Use an order ID to fetch the order details
With callbacks, this looks like:
getUser(1, function(error, user) {
if (error) {
console.log("Error fetching user:", error.message);
return;
}
getOrders(user.id, function(error, orders) {
if (error) {
console.log("Error fetching orders:", error.message);
return;
}
getOrderDetails(orders[0].id, function(error, details) {
if (error) {
console.log("Error fetching details:", error.message);
return;
}
console.log("Order details:", details);
});
});
});
Each async step pushes the code one level deeper. Add another step, and you add another level of indentation. This pattern has a name: callback hell, sometimes called the pyramid of doom — because the code forms a triangle of ever-deepening nesting as it grows to the right.
getUser(
getOrders(
getOrderDetails(
processPayment(
sendConfirmation(
// we have lost the thread entirely
)
)
)
)
)
The problems with deeply nested callbacks are real:
Error handling is repeated. Each level needs its own error check. If you forget one, errors silently disappear. If you want consistent error handling, you duplicate the same pattern at every level.
The logic is hard to follow. The sequence of operations is obvious in plain English: get user, then get orders, then get details. In nested callbacks, that sequence is encoded in the nesting structure rather than in the reading order of the code. You have to trace the indentation levels to understand what happens when.
Refactoring is painful. To change the order of operations, or to add a step in the middle, you restructure the nesting — which means touching every level of the code.
Reusability suffers. The logic at each level is buried inside a callback, not in a named, reusable function. Extracting a piece of it means untangling the nesting first.
A partial fix: named callbacks
One way to reduce the visual nesting is to name each callback and move it out of the inline position:
function handleDetails(error, details) {
if (error) { console.log("Error:", error.message); return; }
console.log("Order details:", details);
}
function handleOrders(error, orders) {
if (error) { console.log("Error:", error.message); return; }
getOrderDetails(orders[0].id, handleDetails);
}
function handleUser(error, user) {
if (error) { console.log("Error:", error.message); return; }
getOrders(user.id, handleOrders);
}
getUser(1, handleUser);
This reads top-to-bottom. The nesting is gone. But the logic is now fragmented across four separate functions that only make sense together. You gain readability but lose cohesion. The code is less deeply nested and more confusing to trace as a whole.
This is the ceiling of what callbacks can offer for sequential async code. It is good enough for simple cases, and genuinely problematic for complex ones.
The Flow of a Callback
To make the execution order concrete, here is exactly what happens in a simple async callback scenario:
console.log("1. Start");
setTimeout(function() {
console.log("3. Inside callback (runs after 1 second)");
}, 1000);
console.log("2. After setTimeout");
Step by step:
"1. Start"is logged — synchronous, runs immediately.setTimeoutis called. JavaScript registers the callback with a 1-second timer and moves on. The callback is not called yet."2. After setTimeout"is logged — synchronous, runs immediately.The current synchronous code is done. JavaScript checks the timer.
After 1 second, the timer fires. JavaScript calls the registered callback.
"3. Inside callback"is logged.
The key point: steps 1–3 happen in sequence without any pause. Step 6 happens separately, after the main thread has finished. This is the event loop at work — JavaScript handles synchronous code first, then processes queued callbacks.
Wrapping Up
Callbacks are the original building block of asynchronous JavaScript. They are how the language handles anything that takes time — network requests, timers, user events, file operations — without freezing everything while it waits.
The concept is simple: pass a function, get it called later. The pattern appears everywhere, from forEach to addEventListener to Node.js APIs. Understanding callbacks is not optional — it is the prerequisite for understanding everything else in async JavaScript.
The nesting problem is real, and it is solved by Promises and async/await, which are built on exactly the same idea but expose it with a cleaner interface. Before tackling those, getting comfortable with callbacks gives you the mental model that makes Promises feel like a natural evolution rather than a new concept entirely.
Comments
Join the discussionNo comments yet. Be the first to comment.
