JavaScript Callbacks Explained
Learn why callbacks exist, how async execution works in JavaScript, and when to move on to Promises — with hands-on exercises.

👋 Hey, I'm Mohd Kaif – a student documenting my journey through code. I write about what I'm learning in real-time – the wins, the struggles, and the "aha!" moments. From JavaScript and React to backend systems with Node.js, databases, DevOps, TypeScript, and AI integrations. This blog is my public learning journal: honest, evolving, and always exploring. If you're curious about any of these topics, let's learn and build together!
You know that moment when you write what feels like perfectly logical code, run it, and the console just stares back at you with undefined?
You try logging the variable. Still undefined. You Google it. You read three Stack Overflow answers. You're more confused than when you started.
If that sounds familiar, you're probably running into JavaScript's asynchronous behavior — and callbacks are the first real tool designed to solve it. But most explanations skip straight to the syntax without ever explaining the problem. So you end up memorizing a pattern without understanding why it exists.
This article fixes that.
Ask yourself — have you ever:
Written a function that fetches data, but the returned value is always
undefined?Copied callback syntax from a tutorial without really knowing what it's doing?
Wondered why
setTimeoutruns "out of order" compared to the rest of your code?Felt like async JavaScript operates on rules that nobody explained to you?
If you nodded at any of these, you're in exactly the right place.
The problem isn't that you're a bad developer. The problem is that callbacks solve a non-obvious issue in JavaScript's execution model — and without seeing that issue first, the solution never quite makes sense.
✅ What You'll Learn
Why JavaScript needs callbacks in the first place (the real reason)
How functions-as-values make callbacks possible
When callbacks run, and why timing matters
How callbacks are used in timers, events, and array methods
Why nested callbacks become a problem — and what comes next
No prerequisites beyond basic JavaScript syntax (variables, functions, loops)
Start Here: The Bug That Explains Everything
Before defining what a callback is, look at this code and predict what it prints:
function fetchUserData() {
setTimeout(() => {
return "Aman's profile loaded";
}, 2000);
}
const data = fetchUserData();
console.log(data);
Go ahead. Take a guess.
undefined
Not "Aman's profile loaded". Not an error. Just undefined.
This surprises almost every developer the first time. The function clearly returns a string — so why is data undefined?
Here's the core reason: fetchUserData finishes and returns before the setTimeout ever fires. The return statement inside setTimeout returns a value to nowhere — it's inside a delayed function that nobody is listening to.
JavaScript didn't wait. It moved on. And your data got lost.
This is the exact problem callbacks were designed to solve.
First Principle: Functions Are Values in JavaScript
To understand how callbacks work, you need to internalize one fact that JavaScript beginners often gloss over:
In JavaScript, a function is a value — just like a string, number, or object.
That means you can assign a function to a variable, store it in an array, and — crucially — pass it as an argument to another function.
function greet(name) {
return `Hello, ${name}!`;
}
function processUser(callback) {
const userName = "Aman";
return callback(userName); // call the function we were given
}
const result = processUser(greet);
console.log(result); // Hello, Aman!
Notice what happened:
greetis passed intoprocessUserwithout parentheses — we're passing the function itself, not calling itprocessUserstores it under the namecallbackand calls it laterThe caller decides what to do; the receiver decides when to do it
That handoff — passing a function so someone else can call it later — is the entire mechanism behind callbacks.
🏋️ Exercise 1
Write a function called runTwice that accepts a callback and calls it exactly two times. Then pass in a function that logs "Hello!" to confirm it works.
function runTwice(callback) {
// your code here
}
// Expected output:
// Hello!
// Hello!
Try it before reading further — writing it once beats reading it three times.
What a Callback Function Actually Is
Now you can define it properly:
A callback function is a function passed as an argument to another function, to be executed at a later time.
It's not a special syntax. There's no callback keyword. It's purely about how a function is used, not what it is.
The word "callback" is just a descriptive name: you're handing a function to someone else and saying, "call this back when you're ready."
Here's the clearest possible example:
function doWork(callback) {
console.log("Working...");
callback(); // "call me back" when done
}
function onComplete() {
console.log("Done!");
}
doWork(onComplete);
Output:
Working...
Done!
onComplete doesn't run when you define it. It doesn't run when you pass it in. It runs when doWork decides to call it. You've handed control of timing to the receiving function.
Now Callbacks Make Sense: Solving the Async Problem
Go back to the broken fetchUserData example. Here's how you fix it with a callback:
function fetchUserData(callback) {
setTimeout(() => {
callback("Aman's profile loaded"); // hand the result to the callback
}, 2000);
}
fetchUserData(function(data) {
console.log(data); // "Aman's profile loaded"
});
The shift is subtle but important. Instead of returning the data (which won't work because the data isn't ready yet), you pass a function that handles the data whenever it arrives.
You're no longer asking "give me the result right now." You're saying "when you have the result, run this."
Here's what's happening step by step:
1. fetchUserData(callback) is called
|
v
2. setTimeout schedules the task and returns immediately
|
v
3. JavaScript moves on — it does NOT wait
|
v
. . . 2 seconds pass . . .
|
v
4. The delayed task fires
|
v
5. callback("Aman's profile loaded") is executed
|
v
6. console.log prints the result ✓
JavaScript is single-threaded — it can only do one thing at a time. It can't pause and wait for a timer or network request without freezing everything else (including your UI). Callbacks let you say: "Go handle other things, and ping me when this is ready."
Where You're Already Using Callbacks
Once you see callbacks, you'll spot them everywhere.
Timers
setTimeout(() => {
console.log("2 seconds later");
}, 2000);
The arrow function is a callback. setTimeout calls it after the delay.
Event Listeners
document.getElementById("submit-btn").addEventListener("click", () => {
console.log("Form submitted!");
});
The function isn't called immediately — it's registered and called when the click happens. That's a callback.
Array Methods
const prices = [10, 25, 8, 42];
const discounted = prices.map((price) => price * 0.9);
console.log(discounted); // [9, 22.5, 7.2, 37.8]
The function passed to .map() is a callback. It runs once for each element. You define what to do; .map() decides when and how many times to call it.
Custom Logic Injection
Callbacks also let you write flexible, reusable functions by leaving behavior as a parameter:
function calculate(a, b, operation) {
return operation(a, b);
}
const sum = calculate(10, 5, (a, b) => a + b);
const product = calculate(10, 5, (a, b) => a * b);
console.log(sum); // 15
console.log(product); // 50
Same function. Completely different behavior. That's the power of passing functions as values.
🏋️ Exercise 2
Write a function called filterWith that takes an array and a callback. It should return a new array containing only the elements for which the callback returns true — without using the built-in .filter() method.
function filterWith(arr, callback) {
// your code here
}
const result = filterWith([1, 2, 3, 4, 5, 6], (n) => n % 2 === 0);
console.log(result); // [2, 4, 6]
This is exactly how .filter() works internally.
The Real Problem: When Callbacks Nest
Callbacks work well for a single async step. But real applications rarely have just one.
Imagine building a feature where you need to:
Authenticate the user
Fetch their orders using the user ID
Load the details of their most recent order
Render the result — and handle an error at every step
With callbacks, that looks like this:
authenticateUser(credentials, function(err, user) {
if (err) return handleError(err);
fetchOrders(user.id, function(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, function(err, details) {
if (err) return handleError(err);
renderOrder(details, function(err) {
if (err) return handleError(err);
console.log("Order displayed successfully");
});
});
});
});
This pattern has a name: Callback Hell (sometimes called the Pyramid of Doom).
authenticateUser
└── fetchOrders
└── getOrderDetails
└── renderOrder
└── (your actual logic)
Each level of nesting is another async dependency. The problems compound:
Readability suffers — you're tracking four levels of indentation to follow the logic
Error handling repeats — every callback needs its own
if (err)checkDebugging is painful — stack traces become hard to follow
Reuse is impossible — all this logic is locked inside one deeply nested structure
This isn't a code style issue. It's a structural limitation of callbacks for complex async flows.
🏋️ Exercise 3
Look at the nested example above and try to refactor it by pulling each callback out into a named function. Does it become more readable?
function onAuthenticated(err, user) { /* ... */ }
function onOrdersFetched(err, orders) { /* ... */ }
// continue...
Notice what works — and where you still hit walls. This exercise will make you genuinely appreciate what Promises solve.
Common Mistakes to Avoid
Calling instead of passing:
// ❌ Wrong — executes immediately, passes the return value
setTimeout(doSomething(), 2000);
// ✅ Correct — passes the function reference
setTimeout(doSomething, 2000);
Expecting async functions to return values:
// ❌ This will always be undefined
function loadData() {
fetch("/api/user").then(res => {
return res.json(); // returns to .then(), not to loadData()
});
}
const user = loadData(); // undefined
Use a callback (or better, a Promise) to handle the result instead of returning it.
What to Learn Next
Callbacks are the foundation. Once you're comfortable with them, your next steps should build directly on this mental model:
Promises: A cleaner way to handle async operations that avoids nesting —
.then()chains replace nested callbacks, and.catch()handles all errors in one placeAsync/Await: Syntactic sugar over Promises that makes async code read like synchronous code — once you understand Promises, this becomes intuitive immediately
The Event Loop (deep dive): A dedicated look at the Call Stack, Web APIs, Callback Queue, and Microtask Queue — understanding this makes all async behavior predictable
Error-first callbacks: The Node.js convention of
(err, data)as the callback signature — essential if you work with Node, streams, or legacy server-side code
Each of these builds on what you just learned. You're not starting over — you're going deeper.
💬 Got Questions?
Drop a comment below — whether it's something that didn't click, a bug you're trying to debug, or just a different way of thinking about this. I read every comment and love a good discussion.
Here are topics coming up next in this series:
Promises from scratch: How
.then()and.catch()work under the hood — not just how to use themAsync/Await demystified: Why
awaitisn't magic, and what it's actually doing to your codeThe Event Loop, visualized: A step-by-step walkthrough of exactly why
setTimeout(..., 0)still runs lastError handling patterns in async JavaScript: The strategies that keep production code from silently failing
Found this helpful? Share it with someone who's been staring at undefined and wondering why. And if you're building through this series from the beginning — you're on the right track. Keep going.




