Lesson 2.1: Callbacks and Callback Hell
Duration: 50 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand what callbacks are and why they exist
- Recognize the callback pattern in JavaScript
- Identify the problems with nested callbacks (callback hell)
- Understand why Promises were introduced as a solution
What is a Callback?
A callback is simply a function passed as an argument to another function, to be executed later. The name comes from the idea that the function will "call back" when it is done.
function greet(name: string, callback: (message: string) => void): void {
const message = `Hello, ${name}!`;
callback(message);
}
// We pass a function that will be "called back"
greet('Alice', (msg) => {
console.log(msg); // "Hello, Alice!"
});
Synchronous Callbacks
Some callbacks execute immediately, within the same call stack:
const numbers = [1, 2, 3, 4, 5];
// The callback runs synchronously for each element
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// filter also uses a synchronous callback
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4]
These are predictable - the callback runs immediately, and the next line waits.
Asynchronous Callbacks
Other callbacks execute later, after some operation completes:
console.log('Before setTimeout');
setTimeout(() => {
console.log('Inside setTimeout'); // Runs later
}, 1000);
console.log('After setTimeout');
// Output:
// Before setTimeout
// After setTimeout
// Inside setTimeout (after 1 second)
The callback is stored and called back when the timer finishes. This is asynchronous - the program continues without waiting.
The Callback Pattern
In Node.js and older JavaScript APIs, asynchronous operations use a common pattern called error-first callbacks:
// Simulating a Node.js-style async function
function readFile(
path: string,
callback: (error: Error | null, data: string | null) => void
): void {
// Simulate async file reading
setTimeout(() => {
if (path === '') {
callback(new Error('Path cannot be empty'), null);
} else {
callback(null, `Contents of ${path}`);
}
}, 100);
}
// Using the function
readFile('config.txt', (error, data) => {
if (error) {
console.error('Failed to read file:', error.message);
return;
}
console.log('File contents:', data);
});
The pattern rules:
- The callback is always the last parameter
- The first callback argument is always the error (or
nullif success) - The second argument is the result (or
nullif error)
Chaining Asynchronous Operations
Real applications often need to perform multiple async operations in sequence. For example:
- Read a config file
- Use the config to connect to a database
- Query the database for user data
- Process the user data
Let us simulate this:
type Callback<T> = (error: Error | null, result: T | null) => void;
function getConfig(callback: Callback<{ dbHost: string }>): void {
setTimeout(() => {
callback(null, { dbHost: 'localhost:5432' });
}, 100);
}
function connectToDatabase(
host: string,
callback: Callback<{ query: (sql: string, cb: Callback<unknown[]>) => void }>
): void {
setTimeout(() => {
callback(null, {
query: (sql: string, cb: Callback<unknown[]>) => {
setTimeout(() => cb(null, [{ id: 1, name: 'Alice' }]), 100);
},
});
}, 100);
}
function processUser(user: { id: number; name: string }): void {
console.log(`Processing user: ${user.name}`);
}
Now let us chain these operations with callbacks:
getConfig((configError, config) => {
if (configError) {
console.error('Config error:', configError);
return;
}
connectToDatabase(config!.dbHost, (dbError, db) => {
if (dbError) {
console.error('Database error:', dbError);
return;
}
db!.query('SELECT * FROM users', (queryError, users) => {
if (queryError) {
console.error('Query error:', queryError);
return;
}
const user = users![0] as { id: number; name: string };
processUser(user);
});
});
});
This works, but notice how the code is already getting nested and hard to follow.
Callback Hell
When you have many sequential async operations, callbacks nest inside each other, creating a pyramid shape. This is called callback hell or the pyramid of doom:
// Simulating API calls
function getUser(userId: number, callback: Callback<{ id: number; name: string }>): void {
setTimeout(() => callback(null, { id: userId, name: 'Alice' }), 100);
}
function getPosts(userId: number, callback: Callback<Array<{ id: number; title: string }>>): void {
setTimeout(
() =>
callback(null, [
{ id: 1, title: 'First Post' },
{ id: 2, title: 'Second Post' },
]),
100
);
}
function getComments(
postId: number,
callback: Callback<Array<{ id: number; text: string }>>
): void {
setTimeout(
() =>
callback(null, [
{ id: 1, text: 'Great post!' },
{ id: 2, text: 'Thanks for sharing' },
]),
100
);
}
function getLikes(postId: number, callback: Callback<number>): void {
setTimeout(() => callback(null, 42), 100);
}
// The pyramid of doom
getUser(1, (userError, user) => {
if (userError) {
console.error('User error:', userError);
return;
}
getPosts(user!.id, (postsError, posts) => {
if (postsError) {
console.error('Posts error:', postsError);
return;
}
getComments(posts![0].id, (commentsError, comments) => {
if (commentsError) {
console.error('Comments error:', commentsError);
return;
}
getLikes(posts![0].id, (likesError, likes) => {
if (likesError) {
console.error('Likes error:', likesError);
return;
}
// Finally, we have all the data!
console.log('User:', user!.name);
console.log('First post:', posts![0].title);
console.log('Comments:', comments!.length);
console.log('Likes:', likes);
});
});
});
});
Problems with Callback Hell
- Hard to read: The code grows horizontally, making it difficult to follow
- Hard to maintain: Adding or removing steps requires careful restructuring
- Error handling is repetitive: Each level needs its own error check
- Hard to debug: Stack traces become confusing
- No easy way to handle parallel operations: What if we want to fetch comments AND likes at the same time?
Attempting to Fix Callbacks
Named Functions
One common attempt is to extract callbacks into named functions:
function handleLikes(
user: { id: number; name: string },
post: { id: number; title: string },
comments: Array<{ id: number; text: string }>
) {
return (likesError: Error | null, likes: number | null) => {
if (likesError) {
console.error('Likes error:', likesError);
return;
}
console.log('User:', user.name);
console.log('First post:', post.title);
console.log('Comments:', comments.length);
console.log('Likes:', likes);
};
}
function handleComments(user: { id: number; name: string }, post: { id: number; title: string }) {
return (commentsError: Error | null, comments: Array<{ id: number; text: string }> | null) => {
if (commentsError) {
console.error('Comments error:', commentsError);
return;
}
getLikes(post.id, handleLikes(user, post, comments!));
};
}
// ... and so on
This helps readability but:
- Creates many extra functions
- Makes the flow harder to trace
- Does not solve error handling repetition
Async Libraries
Before Promises, libraries like async.js provided utilities:
// Conceptual example (not actual async.js syntax)
// async.waterfall([
// getConfig,
// connectToDatabase,
// queryUsers,
// processUsers
// ], finalCallback);
These helped, but were not a native solution.
Real-World Example: Nested API Calls
Here is a realistic example of fetching data from an API using callbacks:
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
interface Comment {
id: number;
postId: number;
email: string;
body: string;
}
// Simulating fetch with callbacks (old style)
function fetchUser(id: number, callback: Callback<User>): void {
setTimeout(() => {
callback(null, {
id,
name: 'John Doe',
email: 'john@example.com',
});
}, 200);
}
function fetchUserPosts(userId: number, callback: Callback<Post[]>): void {
setTimeout(() => {
callback(null, [
{ id: 1, userId, title: 'Hello World', body: 'My first post' },
{ id: 2, userId, title: 'TypeScript Tips', body: 'Use strict types' },
]);
}, 200);
}
function fetchPostComments(postId: number, callback: Callback<Comment[]>): void {
setTimeout(() => {
callback(null, [
{ id: 1, postId, email: 'fan@example.com', body: 'Great post!' },
{ id: 2, postId, email: 'reader@example.com', body: 'Very helpful' },
]);
}, 200);
}
// Using these functions leads to callback hell
function getUserWithPostsAndComments(userId: number): void {
fetchUser(userId, (userError, user) => {
if (userError) {
console.error('Failed to fetch user');
return;
}
fetchUserPosts(user!.id, (postsError, posts) => {
if (postsError) {
console.error('Failed to fetch posts');
return;
}
// Get comments for the first post only
fetchPostComments(posts![0].id, (commentsError, comments) => {
if (commentsError) {
console.error('Failed to fetch comments');
return;
}
// Finally display everything
console.log(`User: ${user!.name}`);
console.log(`Posts: ${posts!.length}`);
console.log(`Comments on first post: ${comments!.length}`);
});
});
});
}
getUserWithPostsAndComments(1);
This is only 3 levels deep, and it is already hard to manage. Imagine 5 or 6 levels!
The Promise Solution (Preview)
Promises solve these problems elegantly. Here is a preview of how the same code looks with Promises:
// With Promises (we will learn this in the next lesson)
function fetchUserPromise(id: number): Promise<User> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id, name: 'John Doe', email: 'john@example.com' });
}, 200);
});
}
function fetchUserPostsPromise(userId: number): Promise<Post[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, userId, title: 'Hello World', body: 'My first post' },
{ id: 2, userId, title: 'TypeScript Tips', body: 'Use strict types' },
]);
}, 200);
});
}
function fetchPostCommentsPromise(postId: number): Promise<Comment[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 1, postId, email: 'fan@example.com', body: 'Great post!' },
{ id: 2, postId, email: 'reader@example.com', body: 'Very helpful' },
]);
}, 200);
});
}
// Clean, flat, readable!
fetchUserPromise(1)
.then((user) => {
console.log(`User: ${user.name}`);
return fetchUserPostsPromise(user.id);
})
.then((posts) => {
console.log(`Posts: ${posts.length}`);
return fetchPostCommentsPromise(posts[0].id);
})
.then((comments) => {
console.log(`Comments: ${comments.length}`);
})
.catch((error) => {
console.error('Something went wrong:', error);
});
Notice how:
- The code is flat, not nested
- Error handling is centralized in one
.catch() - Each step is clear and readable
We will learn how to write this in the next lesson!
Exercises
Exercise 1: Identify the Callback
Which argument is the callback in each function call?
// 1
setTimeout(() => console.log('Done'), 1000);
// 2
const filtered = [1, 2, 3].filter((n) => n > 1);
// 3
button.addEventListener('click', (event) => {
console.log('Clicked!');
});
// 4
fs.readFile('data.txt', 'utf8', (err, data) => {
console.log(data);
});
Solution
() => console.log("Done")- async callback (runs after 1000ms)(n) => n > 1- sync callback (runs immediately for each element)(event) => { console.log("Clicked!"); }- async callback (runs when clicked)(err, data) => { console.log(data); }- async callback (runs when file is read)
Exercise 2: Count the Nesting Levels
How many levels of nesting are in this callback hell?
getA((errA, a) => {
getB(a, (errB, b) => {
getC(b, (errC, c) => {
getD(c, (errD, d) => {
getE(d, (errE, e) => {
console.log(e);
});
});
});
});
});
Solution
5 levels of nesting. Each callback adds one level:
- Level 1:
getAcallback - Level 2:
getBcallback - Level 3:
getCcallback - Level 4:
getDcallback - Level 5:
getEcallback
This is classic callback hell - each operation depends on the previous one, creating a pyramid shape.
Exercise 3: Error Handling Count
How many error checks are needed in this callback chain?
fetchUser(1, (userErr, user) => {
if (userErr) {
/* handle */ return;
}
fetchPosts(user.id, (postsErr, posts) => {
if (postsErr) {
/* handle */ return;
}
fetchComments(posts[0].id, (commentsErr, comments) => {
if (commentsErr) {
/* handle */ return;
}
fetchLikes(posts[0].id, (likesErr, likes) => {
if (likesErr) {
/* handle */ return;
}
// Use the data
});
});
});
});
Solution
4 error checks - one for each async operation:
userErrcheckpostsErrcheckcommentsErrchecklikesErrcheck
With Promises, all of these can be handled with a single .catch().
Exercise 4: Convert to Named Functions
Refactor this callback hell using named functions:
getData((err, data) => {
if (err) {
console.error(err);
return;
}
processData(data, (err, processed) => {
if (err) {
console.error(err);
return;
}
saveData(processed, (err, result) => {
if (err) {
console.error(err);
return;
}
console.log('Saved:', result);
});
});
});
Solution
function handleSave(err: Error | null, result: unknown): void {
if (err) {
console.error(err);
return;
}
console.log('Saved:', result);
}
function handleProcess(err: Error | null, processed: unknown): void {
if (err) {
console.error(err);
return;
}
saveData(processed, handleSave);
}
function handleData(err: Error | null, data: unknown): void {
if (err) {
console.error(err);
return;
}
processData(data, handleProcess);
}
// Now the entry point is clean
getData(handleData);
This is flatter but:
- Harder to follow the flow (you need to read bottom-up)
- Still has repetitive error handling
- Creates many functions
Key Takeaways
- Callbacks are functions passed to other functions to be executed later
- Synchronous callbacks run immediately (like
map,filter) - Asynchronous callbacks run later (like
setTimeout, API calls) - Error-first callbacks are a Node.js convention:
(error, result) => {} - Callback hell occurs when multiple async operations are chained, creating nested pyramids
- Callback hell makes code hard to read, maintain, and debug
- Promises solve these problems (covered in the next lesson)
Resources
| Resource | Type | Description |
|---|---|---|
| MDN: Callback function | Documentation | What is a callback |
| Callback Hell | Article | Classic article on the problem |
| Node.js Error-First Callbacks | Documentation | Node.js callback conventions |
Next Lesson
Now that you understand the problems with callbacks, let us learn how Promises provide an elegant solution.