JavaScript Callbacks

Learn how callbacks work, the error-first Node.js pattern, why callback hell happens, and how to write cleaner async code.

Intermediate 9 min read 9 examples

What is a Callback?

A callback is simply a function passed as an argument to another function. The receiving function calls it ("calls it back") at the appropriate time - which may be immediately or after some future event.

JavaScript
// A function that accepts a callback
function greet(name, callback) {
    const message = `Hello, ${name}!`;
    callback(message); // call the callback with a result
}

// Pass a function as an argument
greet('Alice', function(msg) {
    console.log(msg); // 'Hello, Alice!'
});

// Arrow function syntax
greet('Bob', msg => console.log(msg));

// Named function as callback
function logMessage(msg) {
    console.log('[LOG]', msg);
}
greet('Carol', logMessage);

// Callbacks are normal functions - you can define them any way
const handleResult = function(msg) { console.log(msg); };
greet('Dave', handleResult);

Synchronous Callbacks

Synchronous callbacks are called immediately inside the function they are passed to, before the outer function returns. Most array methods use this pattern.

JavaScript
// forEach - callback fires synchronously for each element
const nums = [1, 2, 3];
nums.forEach(n => console.log(n)); // logs 1, 2, 3 in order

// map, filter, sort - all synchronous callbacks
const doubled   = nums.map(n => n * 2);        // [2, 4, 6]
const evens     = nums.filter(n => n % 2 === 0); // [2]
const sorted    = [3,1,2].sort((a, b) => a - b); // [1, 2, 3]

// Custom function with synchronous callback
function transform(value, transformFn) {
    return transformFn(value); // called immediately
}
const result = transform(5, x => x * x); // 25

// Control flow proof: sync callback runs before the line after the call
console.log('before');
[1, 2].forEach(n => console.log('item:', n));
console.log('after');
// Output: before, item: 1, item: 2, after - always in this order

Asynchronous Callbacks

Asynchronous callbacks are called later - after a timer, a network response, or a user event. The code after the async call continues running first.

JavaScript
// setTimeout - callback fires after a delay
console.log('1 - before setTimeout');
setTimeout(function() {
    console.log('3 - inside timeout'); // fires after the current code finishes
}, 1000);
console.log('2 - after setTimeout');
// Output: 1, 2 immediately, then 3 after 1 second

// Event listeners - callback fires when event occurs (could be never)
document.querySelector('#btn').addEventListener('click', function() {
    console.log('Button was clicked - callback fires on each click');
});
console.log('Listener attached - continues immediately');

// Simulating async with a callback (like an old-style API)
function fetchUser(userId, callback) {
    setTimeout(function() {
        const user = { id: userId, name: 'Alice' }; // simulated response
        callback(user);
    }, 500);
}

fetchUser(42, function(user) {
    console.log('Got user:', user.name); // fires after 500ms
});
console.log('fetchUser called - but result not here yet');

Error-First Callbacks

The Node.js convention: the callback always receives (error, result). If the operation succeeded, error is null. If it failed, error is an Error object.

JavaScript
// Node.js fs.readFile uses error-first pattern
const fs = require('fs');
fs.readFile('./data.json', 'utf8', function(err, data) {
    if (err) {
        console.error('Failed to read file:', err.message);
        return; // early return on error - don't use data
    }
    console.log('File contents:', data);
});

// Writing your own error-first callback function
function parseJSON(jsonString, callback) {
    try {
        const result = JSON.parse(jsonString);
        callback(null, result); // null = no error, result = data
    } catch (err) {
        callback(err, null); // error first, no data
    }
}

parseJSON('{"name":"Alice"}', function(err, data) {
    if (err) {
        console.error('Parse error:', err.message);
        return;
    }
    console.log('Parsed:', data.name); // 'Alice'
});

parseJSON('invalid json', function(err, data) {
    if (err) {
        console.error('Parse error:', err.message); // 'Unexpected token...'
        return;
    }
    // This branch won't run
});
Always return after the error branch

After handling an error in a callback, use return to stop execution. Without it, the code below the error check will still run with an undefined or null data argument, causing a second error. The pattern if (err) { ...; return; } is the standard guard.

Callback Hell

When async operations depend on each other, callbacks nest inside callbacks, creating the "pyramid of doom" - code that is hard to read, debug, and handle errors in.

JavaScript
// Callback hell - each step depends on the previous
getUser(userId, function(err, user) {
    if (err) return handleError(err);

    getOrders(user.id, function(err, orders) {
        if (err) return handleError(err);

        getOrderDetails(orders[0].id, function(err, details) {
            if (err) return handleError(err);

            getShipping(details.shippingId, function(err, shipping) {
                if (err) return handleError(err);

                // Finally use the data - deep nesting makes it hard to follow
                render({ user, orders, details, shipping });
            });
        });
    });
});
// Problems: error handling repeated 4 times, flows right instead of down,
// hard to add parallelism, difficult to test each step independently

Named Functions as Callbacks

Extracting anonymous callback functions into named ones is the first step to reducing nesting - before reaching for Promises.

JavaScript
// Refactored with named functions - flat, readable
function handleShipping(err, shipping) {
    if (err) return handleError(err);
    render({ user, orders, details, shipping });
}

function handleDetails(err, details) {
    if (err) return handleError(err);
    getShipping(details.shippingId, handleShipping);
}

function handleOrders(err, orders) {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, handleDetails);
}

function handleUser(err, user) {
    if (err) return handleError(err);
    getOrders(user.id, handleOrders);
}

getUser(userId, handleUser);

// Better - but still sequential, still repetitive error handling
// Promises and async/await solve this much more elegantly

Callbacks vs Promises

FeatureCallbacksPromises / async-await
Error handlingManual check in every callbackOne .catch() or try/catch
NestingPyramids for sequential opsFlat .then() chains / await
Parallel opsManual counter or libraryPromise.all()
Return valuesCannot return from callbackPromises compose naturally
Browser supportAlways supportedAll modern browsers + Node 8+
Still used forEvent listeners, Array methodsNetwork, file I/O, timers
JavaScript
// Wrap a callback-based function in a Promise
function readFilePromise(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if (err) reject(err);
            else resolve(data);
        });
    });
}

// Now you can use async/await
async function loadConfig() {
    try {
        const data = await readFilePromise('./config.json');
        return JSON.parse(data);
    } catch (err) {
        console.error('Failed:', err.message);
    }
}

// Node.js built-in: util.promisify does this automatically
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
const data = await readFile('./config.json', 'utf8');

Frequently Asked Questions

A callback is a function passed as an argument to another function, to be called later - either synchronously (immediately inside the receiving function) or asynchronously (after some event or operation completes). Examples include the function passed to Array.forEach() (synchronous) and the function passed to setTimeout() (asynchronous).

Callback hell (also called the "pyramid of doom") is deeply nested callbacks that make code hard to read, debug, and maintain - each async operation nests another callback inside the previous one. Solutions include: (1) naming your callback functions instead of inlining them, (2) using Promises which allow flat .then() chains, and (3) using async/await which makes async code look synchronous.

The error-first (or Node.js-style) callback convention passes the error as the first argument. If the operation succeeds, the first argument is null and subsequent arguments hold the result. If it fails, the first argument is an Error object. Always check if (err) before using the result. This pattern is the standard in Node.js core APIs like fs.readFile(path, callback).

Yes - wrap it in a new Promise(). In Node.js, util.promisify(fn) does this automatically for error-first callbacks. Example: const readFile = util.promisify(fs.readFile). Then you can use it with await. For browser callbacks, write the wrapper manually: new Promise((resolve, reject) => { doThing(result => resolve(result)) }).