Callbacks
Callbacks are functions passed as arguments to be called later. They're JavaScript's original async pattern โ understand them to understand Promises and async/await.
What is a Callback?
A callback is a function that you pass as an argument to another function, to be called at some later time:
function greet(name, callback) {
const message = `Hello, ${name}!`;
callback(message); // call the callback with the result
}
greet("Alice", function(msg) {
console.log(msg); // "Hello, Alice!"
});
// With arrow function
greet("Bob", (msg) => console.log(msg));
This is synchronous โ the callback runs immediately. But callbacks really shine in async code.
Why Callbacks Exist
JavaScript runs on a single thread. Operations like network requests, file reads, and timers can't block that thread. Callbacks let you say: "Start this operation, and when it's done, call this function."
// setTimeout โ async callback after a delay
console.log("1: Start");
setTimeout(() => {
console.log("3: Runs after 1 second");
}, 1000);
console.log("2: Continues immediately");
// Output order: 1, 2, 3
Callbacks in the Browser
// Event listeners โ callback fires on user action
document.getElementById("btn").addEventListener("click", function(event) {
console.log("Button clicked!", event.target);
});
// Array methods use callbacks (synchronous)
const nums = [1, 2, 3, 4, 5];
nums.forEach(function(n) {
console.log(n);
});
nums.filter(n => n > 2); // [3, 4, 5]
nums.map(n => n * 2); // [2, 4, 6, 8, 10]
The Error-First Convention
Node.js popularized the "error-first callback" pattern. The first argument is always an error (or null if success):
const fs = require("fs");
fs.readFile("data.txt", "utf8", function(err, data) {
if (err) {
console.error("Error reading file:", err.message);
return; // handle error and return
}
console.log("File contents:", data);
});
console.log("This runs before the file is read");
Callback Hell
When async operations depend on each other, callbacks nest deeply โ this is called "callback hell" or the "pyramid of doom":
// Simulate async operations (e.g., API calls)
function fetchUser(id, callback) {
setTimeout(() => callback(null, { id, name: "Alice" }), 100);
}
function fetchOrders(userId, callback) {
setTimeout(() => callback(null, [{ id: 1 }, { id: 2 }]), 100);
}
function fetchOrderDetails(orderId, callback) {
setTimeout(() => callback(null, { id: orderId, total: 99 }), 100);
}
// The pyramid of doom:
fetchUser(1, function(err, user) {
if (err) return handleError(err);
fetchOrders(user.id, function(err, orders) {
if (err) return handleError(err);
fetchOrderDetails(orders[0].id, function(err, details) {
if (err) return handleError(err);
// Deeply nested โ hard to read and maintain
console.log("Order details:", details);
});
});
});
Problems with this pattern:
- Hard to read (indentation grows rightward)
- Error handling must be repeated at every level
- Hard to add branching logic
- Difficult to test
Mitigating Callback Hell
Strategy 1 โ Named functions:
function handleDetails(err, details) {
if (err) return handleError(err);
console.log("Details:", details);
}
function handleOrders(err, orders) {
if (err) return handleError(err);
fetchOrderDetails(orders[0].id, handleDetails);
}
function handleUser(err, user) {
if (err) return handleError(err);
fetchOrders(user.id, handleOrders);
}
fetchUser(1, handleUser); // much flatter
Strategy 2 โ Promises (the modern solution):
// Same operations as Promise-based functions
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => fetchOrderDetails(orders[0].id))
.then(details => console.log("Details:", details))
.catch(err => handleError(err));
Promises are covered in the next lesson.
Callbacks with setTimeout and setInterval
// One-time delay
const timeoutId = setTimeout(() => {
console.log("Fires once after 2 seconds");
}, 2000);
// Cancel if needed
// clearTimeout(timeoutId);
// Repeated interval
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Tick ${count}`);
if (count === 5) {
clearInterval(intervalId); // stop after 5 ticks
console.log("Done!");
}
}, 500);
๐กsetTimeout(fn, 0) โ the async trick
setTimeout(fn, 0) doesn't run immediately โ it queues the callback to run "as soon as possible" after the current synchronous code finishes. It's used to defer code to the next iteration of the event loop.
console.log("1");
setTimeout(() => console.log("3"), 0);
console.log("2");
// Output: 1, 2, 3
Parallel Callbacks
The old way to run multiple async operations in parallel:
function parallel(tasks, callback) {
const results = [];
let completed = 0;
tasks.forEach(function(task, index) {
task(function(err, result) {
if (err) return callback(err);
results[index] = result;
completed++;
if (completed === tasks.length) {
callback(null, results);
}
});
});
}
// Modern equivalent: Promise.all()
Key Takeaways
- Callbacks are functions passed as arguments to be called later
- Error-first callbacks: first argument is the error, second is the result
- Callback hell = deeply nested async callbacks โ hard to read and maintain
- Named functions help flatten callback hell
- Modern code uses Promises (then) and async/await instead โ but callbacks are still everywhere in event listeners, array methods, and legacy code
Ready to test your knowledge?
Take a quiz on what you just learned.