Difficulty: Intermediate
A callback is simply a function passed as an argument to another function, to be called ("called back") at a later time. Callbacks come in two flavors: synchronous and asynchronous. A synchronous callback runs immediately during the execution of the function it was passed to - Array.prototype.map is a classic example. An asynchronous callback is scheduled to run at some point in the future, after the current code finishes executing. setTimeout, event listeners, and file I/O operations all use asynchronous callbacks.
setTimeout and setInterval are the most fundamental examples of asynchronous callbacks in JavaScript. setTimeout(fn, delay) schedules fn to run once after at least delay milliseconds. setInterval(fn, delay) schedules fn to run repeatedly every delay milliseconds. It is important to understand that the delay is a minimum, not a guarantee - if the call stack is busy, the callback waits in the task queue until the stack is clear. This is why setTimeout(fn, 0) does not execute immediately but rather as soon as the current execution and microtasks complete.
In Node.js, the error-first callback pattern is a convention where the first argument of a callback is reserved for an error object (or null if no error occurred), and subsequent arguments carry the result data. This pattern standardized error handling before promises existed. Functions like fs.readFile follow this pattern: fs.readFile(path, (err, data) => { ... }). If err is truthy, something went wrong. If null, data contains the result. This convention is critical to understand because many Node.js APIs and npm packages still use it.
Callback hell, also known as the pyramid of doom, occurs when multiple asynchronous operations depend on each other and must be nested. Each subsequent operation is indented further to the right, creating a triangular code shape that is difficult to read, debug, and maintain. Error handling becomes repetitive - you need an if (err) check at every level. Refactoring into named functions helps the readability issue, but the fundamental control flow problem remains.
The deeper issue with callbacks is inversion of control. When you pass a callback to a third-party function, you are trusting that function to call your callback the right number of times (once, not zero or multiple), at the right time, with the right arguments, and to handle errors properly. You have no way to enforce these guarantees. This lack of trust is a design flaw that directly motivated the creation of Promises, which restore control to the caller by providing a standardized, composable mechanism for handling asynchronous results.
Understanding callbacks deeply is essential even in modern JavaScript. Promises and async/await are built on top of callback mechanisms. Event listeners, streams, and many browser APIs still use callbacks. Recognizing when callbacks become unmanageable is the first step toward knowing when and why to reach for promises.
// Synchronous callback - runs immediately
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(n) {
return n * 2;
});
console.log('Doubled:', doubled);
// Asynchronous callback - runs later
console.log('Before setTimeout');
setTimeout(function() {
console.log('Inside setTimeout (1000ms)');
}, 1000);
setTimeout(function() {
console.log('Inside setTimeout (0ms)');
}, 0);
console.log('After setTimeout');
// Even 0ms delay waits for current code to finish
map's callback runs synchronously - doubled is available immediately. setTimeout's callbacks run asynchronously. Even the 0ms setTimeout executes after 'After setTimeout' because it goes through the event loop queue. The 1000ms callback runs last.
// Simulating async file reading with error-first callbacks
function readFile(filename, callback) {
setTimeout(() => {
if (filename === 'missing.txt') {
callback(new Error('File not found: ' + filename), null);
return;
}
callback(null, `Contents of ${filename}`);
}, 100);
}
// Usage with error handling
readFile('data.txt', function(err, data) {
if (err) {
console.log('Error:', err.message);
return;
}
console.log('Success:', data);
});
readFile('missing.txt', function(err, data) {
if (err) {
console.log('Error:', err.message);
return;
}
console.log('Success:', data);
});
The error-first pattern puts the error as the first argument. If null, the operation succeeded and data is in the second argument. If truthy, an error occurred. This convention was the standard in Node.js before promises.
// Simulating sequential async operations
function getUser(id, callback) {
setTimeout(() => callback(null, { id, name: 'Alice' }), 100);
}
function getOrders(userId, callback) {
setTimeout(() => callback(null, [{ id: 1, item: 'Laptop', userId }]), 100);
}
function getOrderDetails(orderId, callback) {
setTimeout(() => callback(null, { orderId, total: 999, status: 'shipped' }), 100);
}
// Callback hell: nested dependencies
getUser(42, function(err, user) {
if (err) { console.log(err); return; }
console.log('User:', user.name);
getOrders(user.id, function(err, orders) {
if (err) { console.log(err); return; }
console.log('Orders:', orders.length);
getOrderDetails(orders[0].id, function(err, details) {
if (err) { console.log(err); return; }
console.log('Order total:', details.total);
console.log('Status:', details.status);
// Imagine more nesting here...
});
});
});
Each operation depends on the previous one's result, forcing deeper nesting. Error handling is duplicated at every level. With more operations, this code becomes a rightward-drifting pyramid that is hard to read, debug, and maintain.
function getUser(id, callback) {
setTimeout(() => callback(null, { id, name: 'Bob' }), 100);
}
function getOrders(userId, callback) {
setTimeout(() => callback(null, [{ id: 10, item: 'Phone', userId }]), 100);
}
function getOrderDetails(orderId, callback) {
setTimeout(() => callback(null, { orderId, total: 599, status: 'delivered' }), 100);
}
// Named functions flatten the nesting
function handleOrderDetails(err, details) {
if (err) { console.log(err); return; }
console.log('Total: #39; + details.total);
console.log('Status: ' + details.status);
}
function handleOrders(err, orders) {
if (err) { console.log(err); return; }
console.log('Found ' + orders.length + ' order(s)');
getOrderDetails(orders[0].id, handleOrderDetails);
}
function handleUser(err, user) {
if (err) { console.log(err); return; }
console.log('User: ' + user.name);
getOrders(user.id, handleOrders);
}
getUser(1, handleUser);
Named functions eliminate the visual nesting, but the code is now spread across multiple function declarations. The control flow is still implicit - you must mentally trace the callback chain. Promises solve this by making the chain explicit and linear.
callbacks, asynchronous callbacks, callback hell, setTimeout, setInterval, error-first callbacks