← Back to all tutorials

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);
});
ArgumentOn SuccessOn Error
First (err)nullError message or Error object
Second (data)The result datanull 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

ProblemDescription
ReadabilityDeeply nested code is hard to follow — the logic flows inward instead of downward
Error handlingEvery level needs its own if (err) check — easy to miss one
MaintainabilityAdding, removing, or reordering steps means restructuring all the nesting
DebuggingStack traces are unhelpful — anonymous functions show up as "anonymous"
Inversion of controlYou 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