Callbacks & the Error-First Convention
A callback is a function you pass into another function to be invoked later — once some work finishes. Before promises and async/await existed, callbacks were the only way to handle asynchronous results in Node.js, and they remain the foundation that everything else is built on. Understanding the error-first convention is essential because thousands of npm packages and several core APIs still use it, and because it explains exactly why the language eventually grew promises.
What a callback is
A callback is simply a function value handed to another function so it can be called at the right moment. When the work is synchronous the callback may run immediately; when the work is asynchronous (a timer, disk read, or network request) Node stores the callback and invokes it later, once the event loop reaches the corresponding completion event.
function each(items, fn) {
for (let i = 0; i < items.length; i++) {
fn(items[i], i); // fn is the callback
}
}
each(['a', 'b', 'c'], (value, index) => {
console.log(`${index}: ${value}`);
});
Output:
0: a
1: b
2: c
The interesting case is the asynchronous callback, where the call does not block and the result arrives later.
console.log('before');
setTimeout(() => {
console.log('callback ran');
}, 100);
console.log('after');
Output:
before
after
callback ran
The error-first convention
Node.js standardized a single shape for asynchronous callbacks so that every API behaves predictably. The rule is: the first argument is always the error, and the result (if any) follows. This is called the error-first or Node-style callback.
The contract is strict:
| Condition | First arg (err) | Remaining args |
|---|---|---|
| Success | null | The result value(s) |
| Failure | An Error object | Usually omitted/undefined |
You must check err before touching the result, because on failure the result is meaningless. A real core API — reading a file with the classic fs callback form — looks like this:
import { readFile } from 'node:fs';
readFile('./config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read config:', err.message);
return; // stop here — data is not valid
}
const config = JSON.parse(data);
console.log('Loaded port:', config.port);
});
Always
return(orelse) after handlingerr. Forgetting to bail out is the single most common callback bug: you log the error and then keep running with an undefined result, often throwing a confusing second error downstream.
Writing your own error-first function follows the same pattern. Pass null as the first argument on success, and an Error on failure:
function divide(a, b, callback) {
if (b === 0) {
callback(new Error('Cannot divide by zero'));
return;
}
callback(null, a / b);
}
divide(10, 2, (err, result) => {
if (err) return console.error(err.message);
console.log('Result:', result);
});
Output:
Result: 5
Never
throwinside an async callback-based function and also call the callback — pick one. Throwing across an asynchronous boundary won’t be caught by the caller’stry/catch, because the stack that called you is long gone. Report errors through the callback.
Callback hell
Callbacks work well for a single operation. The trouble starts when operations depend on each other and must run in sequence. Each step nests inside the previous one’s callback, producing the infamous rightward-drifting pyramid — “callback hell.”
import { readFile, writeFile } from 'node:fs';
readFile('./users.json', 'utf8', (err, raw) => {
if (err) return console.error(err);
const users = JSON.parse(raw);
readFile('./settings.json', 'utf8', (err, settingsRaw) => {
if (err) return console.error(err);
const settings = JSON.parse(settingsRaw);
const report = { users: users.length, theme: settings.theme };
writeFile('./report.json', JSON.stringify(report), (err) => {
if (err) return console.error(err);
console.log('Report written');
});
});
});
Three problems compound here. The indentation grows with every step, making the flow hard to follow. Error handling is repeated at every level (if (err) return ...). And there is no clean way to compose, retry, or run these steps in parallel. Add loops or conditionals and the structure becomes nearly unmaintainable.
How callbacks motivated promises
Promises were introduced to flatten this pyramid. A promise is a first-class object representing a future value, so instead of passing a continuation inward you return a value you can chain on. The same sequence becomes linear, with a single error path:
import { readFile, writeFile } from 'node:fs/promises';
try {
const users = JSON.parse(await readFile('./users.json', 'utf8'));
const settings = JSON.parse(await readFile('./settings.json', 'utf8'));
const report = { users: users.length, theme: settings.theme };
await writeFile('./report.json', JSON.stringify(report));
console.log('Report written');
} catch (err) {
console.error(err);
}
The nesting is gone, errors flow to a single catch, and the code reads top to bottom. Note the import switched from node:fs to node:fs/promises — most core modules now ship a promise-based variant alongside the callback one.
When you only have a callback-based API, you can bridge it to a promise with the built-in node:util helper promisify:
import { promisify } from 'node:util';
import { readFile } from 'node:fs';
const readFileAsync = promisify(readFile);
const data = await readFileAsync('./config.json', 'utf8');
console.log('Bytes read:', data.length);
promisify works precisely because the source function follows the error-first convention — it knows the first argument is the error to reject with, and the rest is the value to resolve with.
Best practices
- Always check
errfirst andreturn(orelse) immediately so you never run success logic on a failed result. - Pass
nullas the first argument on success and a realErrorinstance (not a string) on failure, so callers get a stack trace. - Report errors through the callback; never
throwacross an asynchronous boundary where notry/catchcan catch it. - Call the callback exactly once — guard against double-invocation, which causes duplicated work and subtle bugs.
- Prefer the promise-based core APIs (
node:fs/promises, etc.) andasync/awaitfor new code; reserve raw callbacks for performance-critical hot paths or legacy interop. - Wrap legacy callback APIs with
util.promisifyinstead of hand-nesting, so you keep linear, composable control flow.