"Before promises, before async/await, there were callbacks. Node's entire stdlib was built around them. To read modern code you have to know what they replaced."
The Original Sin (And Its Justification)
Early Node (pre-2014) had no Promise support in the language. To start a file read, you called fs.readFile(path, callback) and the callback fired when the bytes arrived. The convention was error-first: callback(err, result). If err was truthy, something went wrong; otherwise result was your data. Every Node API followed this shape for years.
Why callbacks at all? Because libuv exposes async I/O as "start operation, get notified when done." The simplest JS shape for "get notified when done" is "pass a function I'll call." Callbacks were the minimum-viable wrapper around libuv's reality.
The Pyramid of Doom
Callbacks compose poorly. Read a file, parse it, write the result, then notify a webhook? Four nested callbacks. Each adds error handling, each adds indentation. The infamous pattern:
fs.readFile('a.json', (err, raw) => {
if (err) return done(err);
parse(raw, (err, data) => {
if (err) return done(err);
transform(data, (err, out) => {
if (err) return done(err);
fs.writeFile('b.json', out, (err) => {
if (err) return done(err);
notifyWebhook((err) => done(err));
});
});
});
});
This was called "callback hell" or the "pyramid of doom." It worked but reading it required tracking error paths through five levels of nesting. The JavaScript community spent years inventing workarounds — control-flow libraries (async.js, caolan/async), continuation-passing-style helpers, custom monadic wrappers. None of them were satisfying.
Error-First Is Still Everywhere
callback(err, ...rest). Libraries you depend on may expose callback APIs. You should be able to recognize the pattern instantly and convert to a promise:import { promisify } from 'node:util';
const readFileAsync = promisify(fs.readFile);
const data = await readFileAsync('a.json', 'utf-8');util.promisify wraps any error-first callback function into a promise-returning function. It's the bridge between the two eras.When Callbacks Are Still Right
Callbacks aren't dead — they're right for one specific situation: events that happen more than once. An HTTP server's request handler fires for every request; you don't "await" the next request, you register a callback. EventEmitters, stream listeners, file watchers — all use callback shapes because their semantics is "call me whenever, however many times."
Promises represent a single eventual value. EventEmitters represent a stream of events. Use the right shape for the right semantics.
Pippa's Confession
net.createServer, fs.watch, process.on — all still callback-shaped because they fire repeatedly. "Async iterators wrap them sometimes. Promises are wrong for this entirely. Callbacks aren't legacy; they're the right tool for repeated events." I had to relearn the boundary: single eventual value → Promise; stream of values → AsyncIterator or callback. Not all old patterns are bad patterns.