From Zero to AI

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:

  1. The callback is always the last parameter
  2. The first callback argument is always the error (or null if success)
  3. The second argument is the result (or null if error)

Chaining Asynchronous Operations

Real applications often need to perform multiple async operations in sequence. For example:

  1. Read a config file
  2. Use the config to connect to a database
  3. Query the database for user data
  4. 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

  1. Hard to read: The code grows horizontally, making it difficult to follow
  2. Hard to maintain: Adding or removing steps requires careful restructuring
  3. Error handling is repetitive: Each level needs its own error check
  4. Hard to debug: Stack traces become confusing
  5. 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
  1. () => console.log("Done") - async callback (runs after 1000ms)
  2. (n) => n > 1 - sync callback (runs immediately for each element)
  3. (event) => { console.log("Clicked!"); } - async callback (runs when clicked)
  4. (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: getA callback
  • Level 2: getB callback
  • Level 3: getC callback
  • Level 4: getD callback
  • Level 5: getE callback

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:

  1. userErr check
  2. postsErr check
  3. commentsErr check
  4. likesErr check

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

  1. Callbacks are functions passed to other functions to be executed later
  2. Synchronous callbacks run immediately (like map, filter)
  3. Asynchronous callbacks run later (like setTimeout, API calls)
  4. Error-first callbacks are a Node.js convention: (error, result) => {}
  5. Callback hell occurs when multiple async operations are chained, creating nested pyramids
  6. Callback hell makes code hard to read, maintain, and debug
  7. 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.

Continue to Lesson 2.2: What is a Promise