What is a Callback?
A callback is simply a function passed as an argument to another function. The receiving function calls it ("calls it back") at the appropriate time - which may be immediately or after some future event.
// A function that accepts a callback
function greet(name, callback) {
const message = `Hello, ${name}!`;
callback(message); // call the callback with a result
}
// Pass a function as an argument
greet('Alice', function(msg) {
console.log(msg); // 'Hello, Alice!'
});
// Arrow function syntax
greet('Bob', msg => console.log(msg));
// Named function as callback
function logMessage(msg) {
console.log('[LOG]', msg);
}
greet('Carol', logMessage);
// Callbacks are normal functions - you can define them any way
const handleResult = function(msg) { console.log(msg); };
greet('Dave', handleResult);
Synchronous Callbacks
Synchronous callbacks are called immediately inside the function they are passed to, before the outer function returns. Most array methods use this pattern.
// forEach - callback fires synchronously for each element
const nums = [1, 2, 3];
nums.forEach(n => console.log(n)); // logs 1, 2, 3 in order
// map, filter, sort - all synchronous callbacks
const doubled = nums.map(n => n * 2); // [2, 4, 6]
const evens = nums.filter(n => n % 2 === 0); // [2]
const sorted = [3,1,2].sort((a, b) => a - b); // [1, 2, 3]
// Custom function with synchronous callback
function transform(value, transformFn) {
return transformFn(value); // called immediately
}
const result = transform(5, x => x * x); // 25
// Control flow proof: sync callback runs before the line after the call
console.log('before');
[1, 2].forEach(n => console.log('item:', n));
console.log('after');
// Output: before, item: 1, item: 2, after - always in this order
Asynchronous Callbacks
Asynchronous callbacks are called later - after a timer, a network response, or a user event. The code after the async call continues running first.
// setTimeout - callback fires after a delay
console.log('1 - before setTimeout');
setTimeout(function() {
console.log('3 - inside timeout'); // fires after the current code finishes
}, 1000);
console.log('2 - after setTimeout');
// Output: 1, 2 immediately, then 3 after 1 second
// Event listeners - callback fires when event occurs (could be never)
document.querySelector('#btn').addEventListener('click', function() {
console.log('Button was clicked - callback fires on each click');
});
console.log('Listener attached - continues immediately');
// Simulating async with a callback (like an old-style API)
function fetchUser(userId, callback) {
setTimeout(function() {
const user = { id: userId, name: 'Alice' }; // simulated response
callback(user);
}, 500);
}
fetchUser(42, function(user) {
console.log('Got user:', user.name); // fires after 500ms
});
console.log('fetchUser called - but result not here yet');
Error-First Callbacks
The Node.js convention: the callback always receives (error, result). If the operation succeeded, error is null. If it failed, error is an Error object.
// Node.js fs.readFile uses error-first pattern
const fs = require('fs');
fs.readFile('./data.json', 'utf8', function(err, data) {
if (err) {
console.error('Failed to read file:', err.message);
return; // early return on error - don't use data
}
console.log('File contents:', data);
});
// Writing your own error-first callback function
function parseJSON(jsonString, callback) {
try {
const result = JSON.parse(jsonString);
callback(null, result); // null = no error, result = data
} catch (err) {
callback(err, null); // error first, no data
}
}
parseJSON('{"name":"Alice"}', function(err, data) {
if (err) {
console.error('Parse error:', err.message);
return;
}
console.log('Parsed:', data.name); // 'Alice'
});
parseJSON('invalid json', function(err, data) {
if (err) {
console.error('Parse error:', err.message); // 'Unexpected token...'
return;
}
// This branch won't run
});
After handling an error in a callback, use return to stop execution. Without it, the code below the error check will still run with an undefined or null data argument, causing a second error. The pattern if (err) { ...; return; } is the standard guard.
Callback Hell
When async operations depend on each other, callbacks nest inside callbacks, creating the "pyramid of doom" - code that is hard to read, debug, and handle errors in.
// Callback hell - each step depends on the previous
getUser(userId, function(err, user) {
if (err) return handleError(err);
getOrders(user.id, function(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, function(err, details) {
if (err) return handleError(err);
getShipping(details.shippingId, function(err, shipping) {
if (err) return handleError(err);
// Finally use the data - deep nesting makes it hard to follow
render({ user, orders, details, shipping });
});
});
});
});
// Problems: error handling repeated 4 times, flows right instead of down,
// hard to add parallelism, difficult to test each step independently
Named Functions as Callbacks
Extracting anonymous callback functions into named ones is the first step to reducing nesting - before reaching for Promises.
// Refactored with named functions - flat, readable
function handleShipping(err, shipping) {
if (err) return handleError(err);
render({ user, orders, details, shipping });
}
function handleDetails(err, details) {
if (err) return handleError(err);
getShipping(details.shippingId, handleShipping);
}
function handleOrders(err, orders) {
if (err) return handleError(err);
getOrderDetails(orders[0].id, handleDetails);
}
function handleUser(err, user) {
if (err) return handleError(err);
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);
// Better - but still sequential, still repetitive error handling
// Promises and async/await solve this much more elegantly
Callbacks vs Promises
| Feature | Callbacks | Promises / async-await |
|---|---|---|
| Error handling | Manual check in every callback | One .catch() or try/catch |
| Nesting | Pyramids for sequential ops | Flat .then() chains / await |
| Parallel ops | Manual counter or library | Promise.all() |
| Return values | Cannot return from callback | Promises compose naturally |
| Browser support | Always supported | All modern browsers + Node 8+ |
| Still used for | Event listeners, Array methods | Network, file I/O, timers |
// Wrap a callback-based function in a Promise
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
// Now you can use async/await
async function loadConfig() {
try {
const data = await readFilePromise('./config.json');
return JSON.parse(data);
} catch (err) {
console.error('Failed:', err.message);
}
}
// Node.js built-in: util.promisify does this automatically
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
const data = await readFile('./config.json', 'utf8');