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