Callbacks

▶ Try It Yourself

A callback is a function passed as an argument to another function, to be called at a later time — when an event fires, a timer elapses, a file loads, or an asynchronous operation completes. Callbacks are the original mechanism for async programming in JavaScript and still appear everywhere: in DOM events, array methods, timers, and Node.js APIs. Understanding callbacks deeply — including their limitations and the “callback hell” problem they can create — is essential before moving on to Promises and async/await.

Callback Patterns

Pattern Example When Callback Fires
Event callback btn.addEventListener('click', fn) When the event occurs
Timer callback setTimeout(fn, 1000) After delay in milliseconds
Array method callback arr.forEach(fn) Once per array element synchronously
Node-style error-first fs.readFile(path, (err, data) => { }) After async I/O completes
Custom callback fetchUser(id, (err, user) => { }) When the caller decides to invoke it

Node-Style Error-First Convention

Position Parameter Value When
1st argument err null on success; Error object on failure
2nd argument data Result value on success; undefined on failure
Check pattern if (err) return handleError(err); Always check err before using data

Callback Hell Warning Signs

Sign Problem Solution
Indentation marching rightward Nested callbacks 3+ levels deep Named functions or Promises
Error handling repeated everywhere Each level must catch its own errors Centralised error handling with Promises
Hard to follow execution order Non-linear code reading async/await for sequential-looking code
Parallel operations complex No clean way to run callbacks concurrently Promise.all()
Note: Callbacks are synchronous or asynchronous depending on how the caller invokes them. arr.forEach(fn) calls fn synchronously — the loop is blocking and completes before the next line. setTimeout(fn, 0) calls fn asynchronously — after the current call stack clears. Understanding which callbacks are synchronous vs asynchronous is critical for predicting execution order.
Tip: Extract nested callbacks into named functions to eliminate callback hell without switching to Promises. getUserData(id, function handleUser(err, user) { getOrders(user.id, function handleOrders(err, orders) {...}) }) becomes readable when each callback is a named function declared separately. Named functions also appear in stack traces, making debugging much easier.
Warning: Never call a callback both synchronously and asynchronously from the same function — this is called “Zalgo” and creates unpredictable execution order. If your function sometimes calls the callback immediately (sync) and sometimes after an async operation, callers cannot reason about when it will fire. Always be consistently async or consistently sync.

Basic Example

// ── Synchronous callback ──────────────────────────────────────────────────
function repeat(times, callback) {
    for (let i = 0; i < times; i++) {
        callback(i);
    }
}

repeat(3, (i) => console.log(`Iteration ${i}`));
// Iteration 0 / Iteration 1 / Iteration 2

// ── Array method callbacks ────────────────────────────────────────────────
const scores = [88, 42, 95, 67, 73, 55, 91];

// forEach — side effects
scores.forEach((score, index) => {
    if (score >= 90) console.log(`Score ${index}: ${score} — PASS (distinction)`);
});

// find — returns first match
const firstPass = scores.find(s => s >= 70);
console.log(firstPass);   // 88

// every / some
const allPass   = scores.every(s => s >= 50);   // false (42 fails)
const anyDistinct = scores.some(s => s >= 90);  // true (95, 91)
console.log(allPass, anyDistinct);

// ── Timer callbacks ───────────────────────────────────────────────────────
console.log('1: Before timeout');

setTimeout(() => {
    console.log('3: Inside timeout — runs after stack clears');
}, 0);

console.log('2: After timeout — runs before the callback!');
// Output: 1, 2, 3 — even with 0ms delay, callback is async

// ── Error-first callback convention ──────────────────────────────────────
function divideAsync(a, b, callback) {
    // Simulate async work — always call back asynchronously
    setTimeout(() => {
        if (typeof a !== 'number' || typeof b !== 'number') {
            callback(new TypeError('Arguments must be numbers'));
            return;
        }
        if (b === 0) {
            callback(new RangeError('Cannot divide by zero'));
            return;
        }
        callback(null, a / b);   // null = no error, then the result
    }, 0);
}

divideAsync(10, 2, (err, result) => {
    if (err) { console.error('Error:', err.message); return; }
    console.log('Result:', result);   // Result: 5
});

divideAsync(10, 0, (err, result) => {
    if (err) { console.error('Error:', err.message); return; }  // Error: Cannot divide by zero
    console.log('Result:', result);
});

// ── Callback hell — what to avoid ─────────────────────────────────────────
function getUser(id, cb)           { setTimeout(() => cb(null, { id, name: 'Alice' }),   100); }
function getOrders(userId, cb)     { setTimeout(() => cb(null, [{ id: 'O1', total: 49 }]), 100); }
function getProduct(orderId, cb)   { setTimeout(() => cb(null, { id: orderId, name: 'Widget' }), 100); }

// Nested pyramid — hard to read, error handling fragmented
getUser(1, (err, user) => {
    if (err) return console.error(err);
    getOrders(user.id, (err, orders) => {
        if (err) return console.error(err);
        getProduct(orders[0].id, (err, product) => {
            if (err) return console.error(err);
            console.log(`${user.name} ordered ${product.name}`);
        });
    });
});

// ── Same logic refactored with named callbacks ────────────────────────────
function handleProduct(err, product)   { if (err) return console.error(err); console.log('Product:', product.name); }
function handleOrders(err, orders)     { if (err) return console.error(err); getProduct(orders[0].id, handleProduct); }
function handleUser(err, user)         { if (err) return console.error(err); getOrders(user.id, handleOrders); }

getUser(1, handleUser);  // flat, readable — same logic as above

How It Works

Step 1 — Callbacks Are Just Functions Passed as Arguments

JavaScript treats functions as values. Passing a function to another function is no different from passing a number or string. The receiving function stores the reference and calls it when appropriate — immediately, after a delay, when an event fires, or when data arrives.

Step 2 — The Event Loop Enables Async Callbacks

JavaScript is single-threaded. Async callbacks are registered with the browser or Node.js runtime, which monitors for completion events. When the timer fires or data arrives, the callback is placed in the callback queue. The event loop moves it to the call stack only when the stack is empty — that is why setTimeout 0ms still runs after synchronous code.

Step 3 — Error-First Puts Errors Before Data

The convention (err, data) standardises async error handling. By checking if (err) return at the top of every callback, you handle the failure case first and prevent the success code from running with invalid data. The early return ensures you never access data when err is truthy.

Step 4 — Named Functions Flatten Callback Hell

Extracting each nested callback into a named top-level function eliminates the rightward march of indentation. Each function has a single responsibility, appears in stack traces by name, and can be tested independently. This refactoring does not change the logic — only the structure.

Step 5 — Synchronous Callbacks Run Immediately

forEach, map, and filter invoke their callbacks synchronously — during the current call stack. The loop completes entirely before the next line executes. This contrasts with setTimeout and event listeners, which schedule callbacks for future event loop turns.

Real-World Example: Event Emitter

// event-emitter.js — lightweight pub/sub with callbacks

class EventEmitter {
    constructor() {
        this._events = new Map();
    }

    on(event, callback) {
        if (!this._events.has(event)) {
            this._events.set(event, new Set());
        }
        this._events.get(event).add(callback);
        // Return unsubscribe function (closure over event and callback)
        return () => this.off(event, callback);
    }

    once(event, callback) {
        const wrapper = (...args) => {
            callback(...args);
            this.off(event, wrapper);
        };
        return this.on(event, wrapper);
    }

    off(event, callback) {
        this._events.get(event)?.delete(callback);
    }

    emit(event, ...args) {
        this._events.get(event)?.forEach(cb => cb(...args));
    }
}

// Usage
const bus = new EventEmitter();

const off = bus.on('user:login', (user) => {
    console.log(`Welcome, ${user.name}!`);
});

bus.once('app:ready', () => {
    console.log('App initialised — fires once only');
});

bus.emit('user:login', { name: 'Alice' });   // Welcome, Alice!
bus.emit('app:ready');                        // App initialised
bus.emit('app:ready');                        // nothing — once() already fired

off();  // unsubscribe
bus.emit('user:login', { name: 'Bob' });     // nothing — unsubscribed

Common Mistakes

Mistake 1 — Not checking the error argument first

❌ Wrong — accessing data before checking for errors:

fetchData((err, data) => {
    console.log(data.items);   // TypeError if err is set and data is undefined
});

✅ Correct — guard with early return on error:

fetchData((err, data) => {
    if (err) { console.error(err); return; }
    console.log(data.items);   // safe — only runs when no error
});

Mistake 2 — Invoking the callback instead of passing it

❌ Wrong — () calls the function immediately, passes its return value:

btn.addEventListener('click', handleClick());   // handleClick runs NOW, passes its return value

✅ Correct — pass the function reference, not the result:

btn.addEventListener('click', handleClick);   // handleClick runs on click

Mistake 3 — Calling a callback multiple times

❌ Wrong — callback fires twice when both conditions are met:

function process(data, callback) {
    if (!data) callback(new Error('No data'));
    callback(null, data);   // runs even after the error callback above!
}

✅ Correct — return after calling the callback:

function process(data, callback) {
    if (!data) { callback(new Error('No data')); return; }
    callback(null, data);
}

▶ Try It Yourself

Quick Reference

Pattern Example Notes
Pass callback (not call) btn.addEventListener('click', fn) No () — pass reference
Error-first convention fn(err, data) Always check err before data
Inline arrow callback arr.map(x => x * 2) Best for short, single-use callbacks
Named callback function handleResult(err, data) { } Reusable, appears in stack traces
Avoid callback hell Named functions or Promises Max 2 levels of nesting
Return after callback callback(err); return; Prevents double-calling

🧠 Test Yourself

In the Node.js error-first callback convention, what should the first argument be when the operation succeeds?





▶ Try It Yourself