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() |
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.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.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);
}
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 |