What Is a Callback?
A callback is a function you pass to another function as an argument. The receiving function decides when and how to call it. Callbacks can be synchronous (called immediately) or asynchronous (called after some delay or event).
// Synchronous callback β called immediately
function greet(name, callback) {
const message = `Hello, ${name}!`;
callback(message); // called right now
}
greet('Alice', (msg) => console.log(msg));
// Hello, Alice!
// Asynchronous callback β called later
function fetchUserLater(userId, callback) {
console.log('Fetching user...');
setTimeout(() => {
const user = { id: userId, name: 'Bob' };
callback(null, user); // error-first convention
}, 1000);
}
fetchUserLater(42, (err, user) => {
if (err) return console.error(err);
console.log('Got user:', user.name);
});
console.log('This runs BEFORE the callback');
Hello, Alice! Fetching user... This runs BEFORE the callback Got user: Bob β (after ~1 second)
Synchronous Callbacks
Many built-in array methods accept synchronous callbacks. The callback is called immediately, inline, as part of the method's execution.
const numbers = [5, 2, 8, 1, 9, 3];
// forEach β callback for each element
numbers.forEach((n, i) => console.log(`[${i}] = ${n}`));
// sort β comparator callback
const sorted = [...numbers].sort((a, b) => a - b);
console.log('Sorted:', sorted);
// filter + map chain
const result = numbers
.filter(n => n > 3) // [5, 8, 9]
.map(n => n * 2); // [10, 16, 18]
console.log('Filtered & mapped:', result);
// reduce β accumulator callback
const sum = numbers.reduce((acc, n) => acc + n, 0);
console.log('Sum:', sum);
[0] = 5 [1] = 2 ... Sorted: [1, 2, 3, 5, 8, 9] Filtered & mapped: [10, 16, 18] Sum: 28
Asynchronous Callbacks
Asynchronous callbacks are deferred β they run after an I/O operation, timer, or event fires. The current call stack must finish before any async callback runs.
// setTimeout
const timerId = setTimeout(() => {
console.log('Fired after 500ms');
}, 500);
// setInterval β repeated callback
let count = 0;
const intervalId = setInterval(() => {
count++;
console.log(`Tick #${count}`);
if (count === 3) clearInterval(intervalId);
}, 200);
// DOM event listener (browser)
// document.querySelector('#btn').addEventListener('click', (e) => {
// console.log('Button clicked!', e.target);
// });
console.log('Synchronous code runs first');
// Output order: synchronous β ticks β timeout
Synchronous code runs first Tick #1 (after 200ms) Tick #2 (after 400ms) Tick #3 (after 600ms) β interval cleared Fired after 500ms
Node.js Error-First Callback Convention
Node.js standardised the callback signature as (err, data) β the first parameter is always an error (or null if successful), the second is the result. This makes error handling consistent across APIs.
// Simulating Node.js-style fs.readFile
function readConfig(path, callback) {
setTimeout(() => {
if (!path.endsWith('.json')) {
callback(new Error(`Invalid config path: ${path}`), null);
return;
}
callback(null, { theme: 'dark', language: 'en' });
}, 100);
}
// Correct usage: always check err first
readConfig('app.json', (err, config) => {
if (err) {
console.error('Failed to read config:', err.message);
return; // β return early prevents using bad data
}
console.log('Config loaded:', config);
});
readConfig('app.yaml', (err, config) => {
if (err) {
console.error('Failed to read config:', err.message);
return;
}
console.log('Config:', config);
});
Config loaded: { theme: 'dark', language: 'en' }
Failed to read config: Invalid config path: app.yaml
A very common bug is accessing data without checking err. If err is not null, data will be null or undefined β and proceeding will cause a secondary error that obscures the root cause. Always if (err) return handleError(err) as the first line.
Callback Hell β The Pyramid of Doom
When you chain multiple async operations that depend on each other, nested callbacks create a deeply indented, hard-to-read structure called "callback hell" or the "pyramid of doom".
// The pyramid of doom β each step nests inside the previous
function login(username, password, cb) {
setTimeout(() => cb(null, { userId: 1, token: 'abc123' }), 100);
}
function getProfile(userId, cb) {
setTimeout(() => cb(null, { userId, name: 'Alice', role: 'admin' }), 100);
}
function getPermissions(role, cb) {
setTimeout(() => cb(null, ['read', 'write', 'delete']), 100);
}
function loadDashboard(permissions, cb) {
setTimeout(() => cb(null, `Dashboard with [${permissions.join(', ')}]`), 100);
}
// Deeply nested β hard to read, hard to maintain
login('alice', 'pass', (err, session) => {
if (err) return console.error(err);
getProfile(session.userId, (err, profile) => {
if (err) return console.error(err);
getPermissions(profile.role, (err, permissions) => {
if (err) return console.error(err);
loadDashboard(permissions, (err, html) => {
if (err) return console.error(err);
console.log(html);
// β code keeps moving to the right...
});
});
});
});
Dashboard with [read, write, delete]
Flattening Callback Hell with Named Functions
You can flatten the pyramid by extracting each callback into a named function. The logic becomes linear and each step is easy to test in isolation.
// Same operations as above β but flat and readable
function onDashboardLoaded(err, html) {
if (err) return console.error('Dashboard error:', err);
console.log(html);
}
function onPermissionsLoaded(err, permissions) {
if (err) return console.error('Permissions error:', err);
loadDashboard(permissions, onDashboardLoaded);
}
function onProfileLoaded(err, profile) {
if (err) return console.error('Profile error:', err);
getPermissions(profile.role, onPermissionsLoaded);
}
function onLogin(err, session) {
if (err) return console.error('Login error:', err);
getProfile(session.userId, onProfileLoaded);
}
// Clean entry point β reads top to bottom
login('alice', 'pass', onLogin);
Dashboard with [read, write, delete]
Node.js's util.promisify(fn) wraps any error-first callback function into one that returns a Promise. You can then use .then() or async/await for clean sequential logic.
Converting Callbacks to Promises
// Manual promisify wrapper
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
fn(...args, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
};
}
const loginP = promisify(login);
const getProfileP = promisify(getProfile);
const getPermsP = promisify(getPermissions);
const loadDashP = promisify(loadDashboard);
// Now use async/await β clean, linear, easy error handling
async function loadApp() {
try {
const session = await loginP('alice', 'pass');
const profile = await getProfileP(session.userId);
const permissions = await getPermsP(profile.role);
const html = await loadDashP(permissions);
console.log(html);
} catch (err) {
console.error('App load failed:', err.message);
}
}
loadApp();
Dashboard with [read, write, delete]
| Pattern | Readability | Error handling | Modern usage |
|---|---|---|---|
| Nested callbacks | Poor (pyramid) | Manual at each level | Legacy code only |
| Named callbacks | Better (flat) | Manual at each level | Acceptable for simple flows |
| Promises (.then) | Good (chain) | .catch() once | Common |
| async/await | Best (linear) | try/catch once | Preferred |
ποΈ Practical Exercise
Simulate a multi-step file processing pipeline using error-first callbacks:
- Step 1
readFile(path, cb)β returns fake file content after 100ms (fail if path is empty). - Step 2
parseData(content, cb)β parses JSON after 50ms (fail if content is invalid JSON). - Step 3
saveResult(data, cb)β "saves" after 80ms. - Write the pipeline first with nested callbacks, then refactor to named functions, then promisify and use async/await.
π₯ Challenge Exercise
Implement a waterfall(tasks, finalCallback) utility function that runs an array of async error-first callback functions in sequence, passing the result of each to the next. If any task errors, skip remaining tasks and call finalCallback(err). This is similar to the classic async.waterfall pattern.
Interview Questions
- What is the difference between a synchronous and asynchronous callback?
- What is the Node.js error-first callback convention and why does it exist?
- What is callback hell and what are the strategies to avoid it?
- Why do Promises and async/await replace callbacks for complex async flows?
- How would you manually convert an error-first callback API into a Promise?
π Summary
- A callback is a function passed to another function to be invoked later.
- Synchronous callbacks run immediately (forEach, sort); async callbacks run after a delay or event.
- Node.js error-first convention:
(err, data)β always checkerrbefore usingdata. - Callback hell arises from deeply nested async chains β fix with named functions or Promises.
promisifywraps error-first callbacks into Promise-returning functions.- For new code, prefer Promises or async/await over raw callbacks for sequential async logic.
Frequently Asked Questions
No. A callback is just a function passed as an argument. Whether it runs synchronously or asynchronously depends entirely on how the receiving function calls it. Array.forEach calls its callback synchronously; setTimeout calls it asynchronously.
You can mark the callback async, but forEach will not await it β it will fire and forget each promise. Use for...of with await for sequential async iteration, or Promise.all(array.map(async item => ...)) for parallel execution.
require('util').promisify(fn) takes any function that follows the error-first callback convention and returns a new function that returns a Promise instead. Node.js's built-in fs.promises namespace provides pre-promisified versions of all file system methods.