C.W.K.
Stream
Lesson 01 of 05 · published

Callbacks — How Node Started Its Async Life

~12 min · async, callbacks, history

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"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

Even in 2026, the error-first callback convention is alive in Node's stdlib. Every callback-style API still uses 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

For my first year I treated callbacks as historical curiosity. Dad showed me Node's source — 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.

Code

Three eras, one file read·javascript
// Pure error-first callback style (still common in 2026 Node)
import fs from 'node:fs';

fs.readFile('a.json', 'utf-8', (err, raw) => {
  if (err) {
    console.error('read failed:', err);
    return;
  }
  try {
    const data = JSON.parse(raw);
    console.log('got:', data);
  } catch (e) {
    console.error('parse failed:', e);
  }
});

// The same with promisify — bridging to modern style
import { promisify } from 'node:util';
const readFileP = promisify(fs.readFile);
const raw = await readFileP('a.json', 'utf-8');
const data = JSON.parse(raw);
console.log('got:', data);

// Best modern: just use the fs/promises API
import { readFile } from 'node:fs/promises';
const data2 = JSON.parse(await readFile('a.json', 'utf-8'));
Where callbacks are still the right shape·javascript
// Callbacks are STILL right for repeated events
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  // This fires for EVERY request — it's not a one-shot.
  // You can't `await` the next request; you register interest.
  res.end(`hi from ${req.url}`);
});
server.listen(3000);

// EventEmitter — also callback-shaped for the same reason
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.on('user.signup', (user) => {
  // Fires every time someone signs up — not awaitable
  emailWelcome(user);
});

External links

Exercise

Take any callback-style API (fs.readFile, fs.writeFile, dns.lookup — pick one). Write a tiny wrapper using both manual Promise construction AND util.promisify. Compare the two: which is more verbose? Which is easier to get error handling right? Now use the same operation via node:fs/promises (or node:dns/promises). Notice how the modern stdlib already did this work for you.
Hint
Manual Promise: new Promise((resolve, reject) => { fs.readFile(path, (err, data) => { if (err) reject(err); else resolve(data); }); }). promisify does exactly this dance for you. fs/promises does it once at the module level for every fs function — the cleanest path.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.