Callback Functions
Understand callback functions — how they work, how to use them for async operations, and the problem of callback hell.
Callback Functions
A callback is simply a function passed as an argument to another function, to be called later when a task completes. Callbacks are the original way JavaScript handles asynchronous operations. In this episode you will learn how callbacks work, see common patterns, and understand the infamous "callback hell" problem.
What Is a Callback?
function greet(name, callback) {
console.log('Hello, ' + name);
callback();
}
function sayGoodbye() {
console.log('Goodbye!');
}
greet('Alice', sayGoodbye);
// Output:
// Hello, Alice
// Goodbye!
The sayGoodbye function is passed as an argument to greet. When greet finishes its work, it calls callback() which executes sayGoodbye. This is the essence of the callback pattern — "call me back when you are done."
Callbacks in Async Operations
Callbacks become essential with asynchronous code. When you start a task that takes time, you pass a callback to run when the task finishes:
console.log('Start');
setTimeout(function() {
console.log('2 seconds have passed');
}, 2000);
console.log('End');
// Output:
// Start
// End
// 2 seconds have passed
The anonymous function inside setTimeout is a callback. JavaScript does not wait for the timer — it registers the callback and moves on. When the timer finishes, the callback executes.
The Error-First Callback Pattern
In Node.js and many JavaScript libraries, callbacks follow the error-first convention: the first argument is an error (or null on success), and the second is the result data.
function fetchUser(id, callback) {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users/' + id);
xhr.onload = function() {
if (xhr.status === 200) {
callback(null, JSON.parse(xhr.responseText));
} else {
callback('Error: ' + xhr.status, null);
}
};
xhr.onerror = function() {
callback('Network error', null);
};
xhr.send();
}
// Usage:
fetchUser(1, function(err, user) {
if (err) {
console.log('Failed:', err);
return;
}
console.log('User:', user.name);
});
| Argument | On Success | On Error |
|---|---|---|
| First (err) | null | Error message or Error object |
| Second (data) | The result data | null or undefined |
Always check the error first in your callback. If an error exists, handle it and return early. This prevents trying to use data that does not exist.
Common Callback Patterns
Array Methods
const numbers = [1, 2, 3, 4, 5];
// forEach — callback runs for each element
numbers.forEach(function(num) {
console.log(num * 2);
});
// filter — callback returns true/false
const evens = numbers.filter(function(num) {
return num % 2 === 0;
});
// map — callback transforms each element
const doubled = numbers.map(function(num) {
return num * 2;
});
Array methods like forEach, filter, and map are synchronous callbacks — they execute immediately, not asynchronously. But they follow the same pattern of passing a function to be called later (once per element).
Event Listeners
document.getElementById('myBtn').addEventListener('click', function() {
console.log('Button was clicked');
});
// The callback runs whenever the user clicks the button
// We don't know when that will be — it's asynchronous
Sequential Async Operations
// Fetch a user, then fetch their posts
fetchUser(1, function(err, user) {
if (err) return console.log(err);
fetchPosts(user.id, function(err, posts) {
if (err) return console.log(err);
console.log(user.name + ' has ' + posts.length + ' posts');
});
});
When async operations depend on each other, you nest callbacks — the second request starts inside the first callback because it needs the user data.
Callback Hell (Pyramid of Doom)
When you need to chain many async operations that depend on each other, callbacks lead to deeply nested code:
fetchUser(1, function(err, user) {
if (err) return console.log(err);
fetchPosts(user.id, function(err, posts) {
if (err) return console.log(err);
fetchComments(posts[0].id, function(err, comments) {
if (err) return console.log(err);
fetchReplies(comments[0].id, function(err, replies) {
if (err) return console.log(err);
console.log('Replies:', replies);
// Need more data? Another level of nesting...
});
});
});
});
This is called callback hell — each async operation adds another level of indentation. The code forms a pyramid shape that becomes increasingly difficult to read, debug, and maintain.
Problems with Callback Hell
| Problem | Description |
|---|---|
| Readability | Deeply nested code is hard to follow — the logic flows inward instead of downward |
| Error handling | Every level needs its own if (err) check — easy to miss one |
| Maintainability | Adding, removing, or reordering steps means restructuring all the nesting |
| Debugging | Stack traces are unhelpful — anonymous functions show up as "anonymous" |
| Inversion of control | You trust the called function to call your callback correctly (once, with the right arguments) |
Mitigating Callback Hell
There are some techniques to make callbacks more manageable:
1. Use Named Functions
function handleUser(err, user) {
if (err) return console.log(err);
fetchPosts(user.id, handlePosts);
}
function handlePosts(err, posts) {
if (err) return console.log(err);
console.log('Posts:', posts);
}
fetchUser(1, handleUser);
Named functions flatten the nesting and make each step readable. Stack traces also show meaningful names instead of "anonymous."
2. Return Early on Errors
// Instead of:
if (err) {
console.log(err);
} else {
// rest of code
}
// Use:
if (err) return console.log(err);
// rest of code (not indented)
Why Callbacks Are Still Important
Even though Promises and async/await have largely replaced callbacks for async operations, callbacks are still everywhere:
- Event listeners (
addEventListener) - Array methods (
forEach,map,filter) - Node.js core APIs (many still use callbacks)
- Third-party libraries
- Understanding callbacks is required to understand Promises and generators
Key Takeaways
- A callback is a function passed to another function to be executed later
- Callbacks are JavaScript's original mechanism for handling asynchronous operations
- The error-first pattern puts the error as the first argument and data as the second
- Always check for errors first in callbacks and return early to avoid nested else blocks
- Chaining dependent async callbacks leads to "callback hell" — deeply nested, hard-to-read code
- Named functions and early returns help flatten callback nesting
- Promises (next episode) were invented specifically to solve callback hell