Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
16 minLesson 22 of 35
Asynchronous JavaScript

Callbacks & Callback Hell

Callbacks & Callback Hell

Callbacks were JavaScript's original solution to asynchronous programming. Understanding them is essential — both because they're still used everywhere, and because their problems motivated Promises and async/await.

What Is a Callback?

A callback is simply a function passed to another function to be called later:

// Synchronous callback — called immediately
[1, 2, 3].forEach(function(n) {
    console.log(n);  // callback called for each element
});

// Asynchronous callback — called after a delay or event
setTimeout(function() {
    console.log("After 1 second");
}, 1000);

// addEventListener takes a callback
button.addEventListener("click", function(e) {
    console.log("Clicked!");
});

The Error-First Callback Convention

Node.js established a convention where async callbacks always receive (error, result):

const fs = require("fs");

// Node.js fs callbacks: (err, result)
fs.readFile("data.txt", "utf8", function(err, contents) {
    if (err) {
        console.error("Failed to read:", err.message);
        return;
    }
    console.log(contents);
});

// The pattern: always handle error first
function loadUser(id, callback) {
    database.query("SELECT * FROM users WHERE id = ?", [id], function(err, rows) {
        if (err) {
            callback(err, null);
            return;
        }
        callback(null, rows[0]);
    });
}

loadUser(42, function(err, user) {
    if (err) {
        handleError(err);
        return;
    }
    renderUser(user);
});

Callback Hell

When you need to chain multiple async operations, callbacks nest deeper and deeper:

// Real-world scenario: login → load profile → load permissions → load dashboard
loginUser(username, password, function(err, user) {
    if (err) { handleError(err); return; }
    
    loadProfile(user.id, function(err, profile) {
        if (err) { handleError(err); return; }
        
        loadPermissions(user.id, function(err, permissions) {
            if (err) { handleError(err); return; }
            
            loadDashboard(user, profile, permissions, function(err, dashboard) {
                if (err) { handleError(err); return; }
                
                render(dashboard);
                // Nested 4 levels deep — and this is a simple example
            });
        });
    });
});

This is "callback hell" (also called the "pyramid of doom"). Problems:

  • Hard to read
  • Error handling duplicated at every level
  • Difficult to add steps or change order
  • Variables from outer scopes become confusing

Escaping Callback Hell

Named Functions

function onLogin(err, user) {
    if (err) { handleError(err); return; }
    loadProfile(user.id, onProfileLoaded.bind(null, user));
}

function onProfileLoaded(user, err, profile) {
    if (err) { handleError(err); return; }
    loadPermissions(user.id, onPermissionsLoaded.bind(null, user, profile));
}

function onPermissionsLoaded(user, profile, err, permissions) {
    if (err) { handleError(err); return; }
    render({ user, profile, permissions });
}

loginUser(username, password, onLogin);

Async Libraries (pre-Promise era)

// async.js waterfall — sequential async tasks
async.waterfall([
    (cb) => loginUser(username, password, cb),
    (user, cb) => loadProfile(user.id, (err, profile) => cb(err, user, profile)),
    (user, profile, cb) => loadPermissions(user.id, (err, perms) => cb(err, user, profile, perms)),
], function(err, user, profile, permissions) {
    if (err) { handleError(err); return; }
    render({ user, profile, permissions });
});

These patterns work but are complex. Promises were introduced to fix this at the language level.

Callbacks vs Promises

// Callback version
function getUser(id, callback) {
    fetch(`/api/users/${id}`)
        .then(r => r.json())
        .then(data => callback(null, data))
        .catch(err => callback(err, null));
}

// Promise version — what you should write today
function getUser(id) {
    return fetch(`/api/users/${id}`).then(r => r.json());
}

// async/await version — even cleaner
async function getUser(id) {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
}

Promisifying Callbacks

Convert callback-based APIs to Promises:

// Manual promisify
function readFile(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, "utf8", (err, contents) => {
            if (err) reject(err);
            else resolve(contents);
        });
    });
}

// Node.js util.promisify — automatic
const { promisify } = require("util");
const readFileAsync = promisify(fs.readFile);

const contents = await readFileAsync("data.txt", "utf8");

// Node.js fs/promises — already promisified
const { readFile } = require("fs/promises");
const data = await readFile("data.txt", "utf8");

When Callbacks Are Still Appropriate

Not everything needs to be a Promise:

// Event handlers — callbacks are the right tool
button.addEventListener("click", handleClick);

// Array methods — synchronous callbacks are fine
const doubled = numbers.map(n => n * 2);

// Simple one-time callbacks in your own APIs
function processItems(items, onItem, onDone) {
    items.forEach(onItem);
    onDone();
}

// Streaming (ongoing, not one-time)
stream.on("data", (chunk) => process(chunk));
stream.on("end", () => cleanup());

For anything involving async operations (network, file I/O, timers), use Promises and async/await. Callbacks are the foundation but Promises are the standard today.

Next lesson: Promises — creating and consuming them for clean async code.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!