π Callback Functions and Patterns
Before Promises and async/await, JavaScript handled "do this when that's done" with callbacks β functions you hand to other functions to call back later. They still power array methods, event listeners, and Node's core APIs, so understanding them is foundational, not historical.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define a callback function and a higher-order function
- Use callbacks in synchronous contexts (
forEach,map,filter,reduce) and asynchronous ones (timers, events, requests) - Apply the error-first callback convention used throughout Node.js
- Recognize callback hell and refactor it with named functions and modularization
- Build a small event emitter to see callbacks power event-driven design
Estimated Time: 30β40 minutes β’ Difficulty: Intermediate
Hands-on: Refactor a "pyramid of doom" into flat, named, error-handled steps.
In This Lesson
What Is a Callback?
A callback function is simply a function passed as an argument to another function, so that the other function can call it back at the right moment. That's the whole idea β but it's a powerful one.
β Leaving your name at the counter. You order a coffee and give your name. You don't stand frozen at the register β you step aside and do other things. When the drink is ready, the barista calls your name. Your name is the callback; the barista decides when to invoke it.
Any function that accepts a function as an argument (or returns one) is called a higher-order function. Callbacks give you inversion of control: instead of your code deciding when something runs, you hand that decision to another function.
π Key Terms
Callback: a function passed to another function to be invoked later.
Higher-order function: a function that takes a function as an argument and/or returns one.
Inversion of control: you give up deciding when your code runs; the receiving function decides.
Synchronous Callbacks
Not every callback is async. JavaScript's array methods take callbacks and run them immediately, one element at a time, before returning.
const numbers = [1, 2, 3, 4, 5];
// forEach: run a callback for each element (no return value)
numbers.forEach((n) => console.log(n * 2));
// map: build a new array from each callback's return value
const doubled = numbers.map((n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// filter: keep elements where the callback returns true
const evens = numbers.filter((n) => n % 2 === 0);
console.log(evens); // [2, 4]
// reduce: fold the array into a single value
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum); // 15
These callbacks are synchronous: each one finishes before the next element is processed, and the method returns only when every element is done.
Writing your own higher-order function
// Accepts a callback and decides how/when to call it
function withGreeting(name, callback) {
const message = `Hello, ${name}!`;
callback(message);
}
withGreeting('Ray', (msg) => console.log(msg)); // Hello, Ray!
Because withGreeting doesn't hard-code what happens with the message, you can reuse it with any behavior you pass in β logging, rendering, storing, and so on.
Asynchronous Callbacks
Callbacks truly shine with asynchronous work: you register what should happen later, and the runtime invokes it when the event or timer completes (recall the event loop from the previous lesson).
Timers
console.log('Startingβ¦');
setTimeout(function timerCallback() {
console.log('2 seconds have passed!');
}, 2000);
console.log('Timer set β continuing other workβ¦');
// Startingβ¦
// Timer set β continuing other workβ¦
// 2 seconds have passed!
DOM events
const button = document.getElementById('submit-button');
button.addEventListener('click', function onClick(event) {
console.log('Button clicked!', event.target);
});
Network requests (modern fetch)
The classic XMLHttpRequest API used success and error callbacks. Today you'll almost always use fetch, whose .then() takes a callback too β Promises are really a structured layer over the same idea:
fetch(`https://api.example.com/users/${userId}`)
.then((response) => {
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json();
})
.then((data) => {
console.log('User data:', data);
})
.catch((error) => {
console.error('Error fetching user:', error.message);
});
π‘ Same shape, different era
Whether it's addEventListener, setTimeout, or .then(), the pattern is identical: "here's a function β run it when you're ready." Promises and async/await (coming up in later lessons) don't replace this idea; they make it easier to compose.
The Error-First Convention
Async callbacks need a way to report failure. Node.js standardized the error-first callback: the callback's first parameter is an error (or null if all went well), and results follow.
function readConfig(path, callback) {
if (!path.endsWith('.json')) {
// Failure: pass an Error as the FIRST argument
callback(new Error('Only .json files are supported'));
return;
}
// Success: first arg is null, result comes second
setTimeout(() => {
callback(null, { theme: 'dark', from: path });
}, 500);
}
readConfig('settings.json', (err, config) => {
if (err) {
console.error('Failed:', err.message);
return;
}
console.log('Loaded config:', config);
});
β Why error-first works well
- Consistent, predictable parameter order across every API.
- You're nudged to handle the error before touching the result.
- A quick
if (err) return;guard reads cleanly at the top of the callback.
β οΈ You cannot try/catch an async callback
A try/catch around setTimeout(...) won't catch errors thrown inside the later callback β by the time it runs, the surrounding try block is long gone. That limitation is exactly why the error-first parameter exists (and why Promises added .catch()).
Callback Hell & How to Escape It
When one async step depends on the previous one, nesting callbacks quickly produces a rightward-drifting "pyramid of doom" β callback hell.
// Hard to read, hard to error-handle, hard to change
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getRecommendations(orders, (recs) => {
getProductDetails(recs, (products) => {
render(user, orders, recs, products); // deeply nested
});
});
});
});
The problems: it's hard to follow, error handling gets duplicated at every level, and reordering or parallelizing steps is painful.
Fix 1 β Named functions
Pull each anonymous callback out into a named function to flatten the pyramid:
function handleUser(user) { getOrders(user.id, handleOrders); }
function handleOrders(orders) { getRecommendations(orders, handleRecs); }
function handleRecs(recs) { getProductDetails(recs, handleProducts); }
function handleProducts(prods) { render(prods); }
getUser(userId, handleUser); // reads top to bottom
Fix 2 β Modularize with shared state
Group related steps and collect results in one object, which also lets some steps run in parallel:
function loadProfile(userId, done) {
const profile = {};
getUser(userId, (err, user) => {
if (err) return done(err);
profile.user = user;
getOrders(user.id, (err, orders) => {
if (err) return done(err);
profile.orders = orders;
done(null, profile);
});
});
}
π‘ The real fix is coming
These techniques help, but the definitive answer to callback hell is Promises and async/await, which let you write flat, linear async code with a single error path. Callbacks are the foundation those tools are built on β that's why we learn them first.
Callbacks in Event-Driven Code
Callbacks underpin event-driven architecture, where components communicate through named events instead of direct calls. Here's a compact EventEmitter β the same pattern behind the DOM and Node's events module.
class EventEmitter {
constructor() {
this.listeners = {};
}
on(event, callback) {
(this.listeners[event] ??= []).push(callback);
return this; // enable chaining
}
off(event, callback) {
if (!this.listeners[event]) return this;
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
return this;
}
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
emit(event, ...args) {
(this.listeners[event] ?? []).forEach((cb) => cb(...args));
return this;
}
}
// Usage
const users = new EventEmitter();
users.on('login', (user) => console.log(`Welcome, ${user.name}`));
users.on('login', (user) => console.log(`Analytics: login ${user.id}`));
users.once('login', (user) => console.log('This runs only the first time'));
users.emit('login', { id: 7, name: 'Ray' });
β Why event emitters are handy
- Decoupling: the code that emits
loginknows nothing about the listeners. - Many listeners: several parts of the app can react to one event.
- Extensible: add behavior by registering a new listener β no existing code changes.
Hands-on Exercise
ποΈ Flatten the Pyramid
Objective: Turn a nested callback mess into readable, error-handled steps.
Starter code (the problem):
// Three fake async steps using error-first callbacks
function getUser(id, cb) { setTimeout(() => cb(null, { id, name: 'Ray' }), 300); }
function getOrders(userId, cb) { setTimeout(() => cb(null, ['order-1', 'order-2']), 300); }
function getTotal(orders, cb) { setTimeout(() => cb(null, orders.length * 25), 300); }
// Nested "pyramid" version β your job is to improve this
getUser(1, (e1, user) => {
getOrders(user.id, (e2, orders) => {
getTotal(orders, (e3, total) => {
console.log(`${user.name} spent $${total}`);
});
});
});
Your tasks:
- Refactor to use named functions so the flow reads top-to-bottom instead of nesting.
- Add proper error-first handling at every step (
if (err) return handleError(err);). - Make sure the final line still logs
Ray spent $50.
π‘ Hint
Give each step a handler that receives the previous result, checks its error argument first, then calls the next step. A single shared handleError function keeps error handling in one place.
β Solution
function handleError(err) {
console.error('Something failed:', err.message);
}
function start() { getUser(1, onUser); }
function onUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, (e, orders) => onOrders(e, user, orders));
}
function onOrders(err, user, orders) {
if (err) return handleError(err);
getTotal(orders, (e, total) => onTotal(e, user, total));
}
function onTotal(err, user, total) {
if (err) return handleError(err);
console.log(`${user.name} spent $${total}`); // Ray spent $50
}
start();
Flat, named, and every step guards its error before continuing. In upcoming lessons you'll rewrite this same flow in a handful of lines with async/await.
π― Quick Quiz
Question 1: What exactly is a callback function?
Question 2: In the Node.js error-first convention, what is the callback's first argument?
Question 3: Which is a genuine problem with deeply nested "callback hell"?
Summary & Quiz
π Key Takeaways
- A callback is a function passed to another function to be run later; a function that takes one is a higher-order function.
- Callbacks power both synchronous array methods and asynchronous timers, events, and requests.
- The error-first convention (
(err, result) => β¦) gives async code a consistent way to report failure. - Callback hell comes from deep nesting; named functions and modularization flatten it.
- Callbacks underpin event-driven patterns like the
EventEmitterβ and are the foundation Promises build on.
π Further Reading
- MDN β Callback function
- MDN β Introducing callbacks
- Eloquent JavaScript β Asynchronous Programming
π What's Next?
You've felt the pain callbacks can cause at scale. Next we meet the tool designed to fix it: the Promise β its structure, its three states, and how it turns pyramids into flat chains.
π Foundations locked in!
Callbacks are the bedrock of async JavaScript. Now let's build something cleaner on top of them.