Functions that call other functions — and why that's the foundation of everything async.
Here's something that blew my mind when I first learned it: in JavaScript, functions are values. You can store them in variables, put them in arrays, pass them as arguments to other functions, and even return them from functions.
That last one — passing a function as an argument — is the entire concept behind callbacks. And once you understand callbacks, you understand how setTimeout works, how addEventListener works, how array methods like map() and filter() work, and how JavaScript handles asynchronous operations.
Callbacks are everywhere. I just didn't realize it until the ChaiCode Web Dev Cohort 2026 connected the dots. Let me show you what I mean.
Functions Are Values in JavaScript
Before we talk about callbacks, we need to establish this fundamental truth: functions in JavaScript are first-class citizens. That means they're treated just like any other value — numbers, strings, objects.
// Storing a function in a variable
const greet = function (name) {
return `Hello, ${name}!`;
};
// Storing functions in an array
const operations = [
(a, b) => a + b,
(a, b) => a - b,
(a, b) => a * b,
];
console.log(operations[0](5, 3)); // 8
console.log(operations[2](5, 3)); // 15
If a function is just a value, then you can pass it to another function as an argument. And that's exactly what a callback is.
What Is a Callback Function?
A callback is a function that you pass into another function as an argument, and the receiving function calls it back at some point during its execution.
The name literally tells you what it does: "call me back when you're ready."
The Simplest Callback
function processUserInput(callback) {
const name = "Pratham";
callback(name);
}
processUserInput((name) => {
console.log(`Welcome, ${name}!`);
});
// "Welcome, Pratham!"
Let's trace what happens:
- We call
processUserInputand pass it a function (the callback) - Inside
processUserInput,nameis set to"Pratham" - The
callbackis called withnameas the argument - Our passed function runs:
console.log("Welcome, Pratham!")
Visual: Function Calling Another Function
processUserInput(callback)
│
↓
┌─────────────────────┐
│ processUserInput() │
│ │
│ const name = ... │
│ callback(name) ──────→ Our function runs!
│ ↑ │ ↓
│ "call me back" │ console.log("Welcome, Pratham!")
└─────────────────────┘
The key idea: the caller decides what to do. The receiving function decides when to do it.
Passing Functions as Arguments — More Examples
You've actually been using callbacks all along. Let me prove it.
Array Methods Are Callbacks
const numbers = [1, 2, 3, 4, 5];
// The function inside map() is a CALLBACK
const doubled = numbers.map((num) => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// The function inside filter() is a CALLBACK
const evens = numbers.filter((num) => num % 2 === 0);
console.log(evens); // [2, 4]
// The function inside forEach() is a CALLBACK
numbers.forEach((num) => console.log(num));
// 1, 2, 3, 4, 5
Every time you pass a function to map(), filter(), or forEach(), you're using a callback. You're saying: "Here's what I want done to each element. You handle when and how to loop."
Event Listeners Are Callbacks
// The function is a callback — it runs when the event happens
document.querySelector("#btn").addEventListener("click", () => {
console.log("Button clicked!");
});
// You're saying: "When a click happens, CALL BACK this function."
setTimeout and setInterval Are Callbacks
// The function runs AFTER 2 seconds — it's called back later
setTimeout(() => {
console.log("2 seconds passed!");
}, 2000);
In all of these cases, you're not calling the function yourself. You're passing it to something else, and that something else calls it at the right time.
Why Callbacks Are Used in Asynchronous Programming
This is where callbacks become essential — not just convenient.
Remember from the sync vs async article: JavaScript is single-threaded. It can't wait around for slow tasks. So instead of blocking, it says: "Start this task. Here's a function to run when you're done. I'll keep going."
That function is a callback.
Synchronous vs Async Callback
// SYNCHRONOUS callback — runs immediately
function calculate(a, b, operation) {
return operation(a, b);
}
const result = calculate(10, 5, (a, b) => a + b);
console.log(result); // 15 — runs right now, no waiting
// ASYNCHRONOUS callback — runs later
console.log("Before");
setTimeout(() => {
console.log("Inside callback — runs later!");
}, 1000);
console.log("After");
// Output: Before → After → Inside callback (1 second later)
Synchronous callbacks run immediately within the function. Asynchronous callbacks are scheduled to run later — after a timer, after an API response, after a user action.
Real-World Async Example: Simulated Data Fetching
function fetchUser(userId, callback) {
console.log(`Fetching user ${userId}...`);
// Simulate API delay
setTimeout(() => {
const user = { id: userId, name: "Pratham", role: "developer" };
callback(user); // "Here's your data — I'm calling you back!"
}, 2000);
}
console.log("Start");
fetchUser(1, (user) => {
console.log(`Got user: ${user.name} (${user.role})`);
});
console.log("Continuing with other work...");
Output:
Start
Fetching user 1...
Continuing with other work...
Got user: Pratham (developer) ← 2 seconds later
The flow:
1. "Start" prints
2. fetchUser() is called → sets up a 2-second timer
3. "Continuing with other work..." prints (JavaScript didn't wait!)
4. After 2 seconds, the callback fires with the user data
Callback Usage in Common Scenarios
1. Reading Files (Node.js)
const fs = require("fs");
fs.readFile("data.txt", "utf8", (error, data) => {
if (error) {
console.log("Error reading file:", error);
return;
}
console.log("File contents:", data);
});
console.log("This runs while the file is being read...");
2. Error-First Callbacks (Node.js Convention)
In Node.js, callbacks follow a convention: the first parameter is always the error (or null if no error):
function getUser(id, callback) {
setTimeout(() => {
if (id <= 0) {
callback(new Error("Invalid user ID"), null);
} else {
callback(null, { id, name: "Pratham" });
}
}, 1000);
}
// Usage
getUser(1, (error, user) => {
if (error) {
console.log("Error:", error.message);
return;
}
console.log("User:", user.name);
});
// "User: Pratham"
getUser(-1, (error, user) => {
if (error) {
console.log("Error:", error.message);
return;
}
console.log("User:", user.name);
});
// "Error: Invalid user ID"
3. Custom Iteration with Callbacks
function repeat(times, callback) {
for (let i = 1; i <= times; i++) {
callback(i);
}
}
repeat(3, (n) => console.log(`Iteration ${n}`));
// Iteration 1
// Iteration 2
// Iteration 3
repeat(5, (n) => console.log("⭐".repeat(n)));
// ⭐
// ⭐⭐
// ⭐⭐⭐
// ⭐⭐⭐⭐
// ⭐⭐⭐⭐⭐
The repeat function controls when (the loop). The callback controls what (the action). Same function, completely different behavior — just by passing a different callback.
4. Confirmation Patterns
function confirmAction(message, onConfirm, onCancel) {
const answer = true; // simulating user clicking "OK"
if (answer) {
onConfirm();
} else {
onCancel();
}
}
confirmAction(
"Delete this item?",
() => console.log("Item deleted! 🗑️"),
() => console.log("Action cancelled."),
);
// "Item deleted! 🗑️"
The Basic Problem: Callback Nesting (Callback Hell)
Callbacks work great for simple, one-level async operations. But what happens when you need to do async things in sequence — where each step depends on the result of the previous one?
The Scenario
You need to:
- Fetch a user
- Use that user's ID to fetch their orders
- Use the order to fetch shipping details
Each step depends on the previous step's result.
The Nested Code
getUser(1, (error, user) => {
if (error) {
console.log("Error:", error);
return;
}
console.log("Got user:", user.name);
getOrders(user.id, (error, orders) => {
if (error) {
console.log("Error:", error);
return;
}
console.log("Got orders:", orders.length);
getShippingDetails(orders[0].id, (error, shipping) => {
if (error) {
console.log("Error:", error);
return;
}
console.log("Shipping to:", shipping.address);
// Need more steps? Nest even deeper...
});
});
});
Nested Callback Execution Flow
getUser(1, callback)
│
└──→ success → getOrders(user.id, callback)
│
└──→ success → getShippingDetails(orderId, callback)
│
└──→ success → finally do something!
Each level pushes the code further RIGHT →→→
This is "Callback Hell" or the "Pyramid of Doom"
Why This Is a Problem
1. READABILITY — The code keeps indenting right. With 5+ levels, it's
nearly impossible to follow.
2. ERROR HANDLING — You're writing the same if (error) check at every
single level. Repetitive and easy to miss.
3. MAINTAINABILITY — Adding a new step means wrapping everything in
another layer of nesting. Removing a step means carefully
restructuring the entire chain.
4. DEBUGGING — When something breaks deep in the nesting, finding
the source is like debugging inside a Russian doll.
The Visual
Code with callbacks:
getUser(1, (err, user) => {
·getOrders(user.id, (err, orders) => {
··getShipping(orders[0].id, (err, ship) => {
···sendEmail(user.email, ship.status, (err, result) => {
····updateDB(result, (err, updated) => {
·····console.log("FINALLY DONE!"); // 5 levels deep 😵
····});
···});
··});
·});
});
This is the "Pyramid of Doom" →→→→→
This is exactly why Promises and async/await were invented — to flatten this nesting and make async code readable. But that's for the next article. Understanding why callbacks have this problem is the first step.
Let's Practice: Hands-On Assignment
Part 1: Write a Simple Callback
function greetUser(name, callback) {
const message = `Hello, ${name}!`;
callback(message);
}
greetUser("Pratham", (msg) => console.log(msg));
// "Hello, Pratham!"
greetUser("Arjun", (msg) => console.log(msg.toUpperCase()));
// "HELLO, ARJUN!"
Part 2: Async Callback with setTimeout
function delayedGreeting(name, delay, callback) {
console.log(`Preparing greeting for ${name}...`);
setTimeout(() => {
callback(`Hey ${name}, welcome to ChaiCode! 🍵`);
}, delay);
}
delayedGreeting("Pratham", 2000, (message) => {
console.log(message);
});
console.log("This prints while waiting...");
// "Preparing greeting for Pratham..."
// "This prints while waiting..."
// "Hey Pratham, welcome to ChaiCode! 🍵" (2 seconds later)
Part 3: Callback with Error Handling
function divide(a, b, callback) {
if (b === 0) {
callback(new Error("Cannot divide by zero!"), null);
} else {
callback(null, a / b);
}
}
divide(10, 2, (err, result) => {
if (err) return console.log("Error:", err.message);
console.log("Result:", result);
});
// "Result: 5"
divide(10, 0, (err, result) => {
if (err) return console.log("Error:", err.message);
console.log("Result:", result);
});
// "Error: Cannot divide by zero!"
Part 4: Experience Callback Nesting
function step1(callback) {
setTimeout(() => {
console.log("Step 1 complete");
callback("data from step 1");
}, 1000);
}
function step2(input, callback) {
setTimeout(() => {
console.log(`Step 2 received: ${input}`);
callback("data from step 2");
}, 1000);
}
function step3(input, callback) {
setTimeout(() => {
console.log(`Step 3 received: ${input}`);
callback("final result");
}, 1000);
}
// Nested callbacks — feel the nesting!
step1((result1) => {
step2(result1, (result2) => {
step3(result2, (finalResult) => {
console.log(`Done! ${finalResult}`);
});
});
});
Key Takeaways
- A callback is a function passed as an argument to another function, to be "called back" at the right time.
- Functions in JavaScript are first-class values — you can pass them around just like numbers or strings. This is what makes callbacks possible.
- You've been using callbacks all along — in
map(),filter(),addEventListener(),setTimeout(), and more. - Callbacks are essential for async programming — they let JavaScript hand off slow tasks and say "call this function when you're done."
- Callback nesting (callback hell) is the main drawback — sequential async operations create deeply nested, hard-to-read code. Promises and async/await solve this.
Wrapping Up
Callbacks are the original way JavaScript handles asynchronous operations. They're simple, powerful, and they're the foundation that Promises and async/await are built on. Understanding why callbacks exist — and where they break down — gives you the full context for why modern async patterns were invented.
I'm working through all of this in the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Callbacks were the "aha" moment where I stopped seeing functions as just blocks of code and started seeing them as values that can be passed around. That mental shift unlocks everything that comes next.
Connect with me on LinkedIn or visit PrathamDEV.in. Next up: Promises — the solution to callback hell.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)