Node started before promises existed in JavaScript, so its original async style was callbacks — and not just any callbacks, a specific convention called error-first callbacks. Everything we do today (promises, async/await) is layered on top of that foundation. Understanding the progression helps when we debug legacy code or interop with older modules.
Error-first callbacks — the original
The convention: every async function takes a callback whose first argument is an error (or null on success), and subsequent arguments are the actual results.
import fs from 'node:fs';
fs.readFile('./config.json', 'utf8', (err, data) => {
if (err) {
console.error('Read failed:', err);
return;
}
console.log('Got:', data);
});
The “first param is the error” convention sounds simple, but in a real app with five nested async calls we end up with callback hell — pyramids of indentation, error handling repeated everywhere, no way to use try/catch.
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) return cb(err);
fs.readFile(JSON.parse(data).next, 'utf8', (err, data2) => {
if (err) return cb(err);
fs.writeFile('out.txt', data2, (err) => {
if (err) return cb(err);
// ...
});
});
});
Promises — chainable, composable
Promises wrap a future value. We attach .then for success, .catch for failure. The chain flattens the pyramid.
import { readFile, writeFile } from 'node:fs/promises';
readFile('config.json', 'utf8')
.then((data) => readFile(JSON.parse(data).next, 'utf8'))
.then((data2) => writeFile('out.txt', data2))
.catch((err) => console.error('Failed:', err));
Better, but still verbose. The real win comes next.
async/await — promises in disguise
async/await is syntactic sugar over promises. An async function always returns a promise. await pauses inside that function until the awaited promise resolves. We get to write async code that reads like sync code.
async function transform() {
try {
const data = await readFile('config.json', 'utf8');
const data2 = await readFile(JSON.parse(data).next, 'utf8');
await writeFile('out.txt', data2);
} catch (err) {
console.error('Failed:', err);
}
}
In simple language: await is a “wait for this, then continue on the next line” marker. The function returns control to the event loop while waiting — Node isn’t blocked.
Sequential vs parallel — the await trap
await runs things one at a time. If three operations don’t depend on each other, that’s wasteful.
// SLOW — 3 sequential round-trips
const user = await fetchUser(id);
const orders = await fetchOrders(id);
const cart = await fetchCart(id);
// FAST — all 3 in parallel, wait for the slowest
const [user, orders, cart] = await Promise.all([
fetchUser(id),
fetchOrders(id),
fetchCart(id),
]);
This is one of the most common perf wins in Node code. Look for “await, await, await” with no data dependency and combine with Promise.all.
Promisification — bridging old code
Some old modules still use error-first callbacks. We don’t want to write .then chains around them. Wrap them with util.promisify.
import { promisify } from 'node:util';
import { exec as execCb } from 'node:child_process';
const exec = promisify(execCb);
const { stdout } = await exec('git rev-parse HEAD');
console.log('Commit:', stdout.trim());
fs.promises is essentially fs callbacks promisified at the source — same API, promise-based.
Top-level await — only in ESM
Old Node modules (CJS) couldn’t await at the top of a file — only inside an async function. ESM modules can. This is huge for startup code.
// app.js — package.json has "type": "module"
import { readFile } from 'node:fs/promises';
const config = JSON.parse(await readFile('./config.json', 'utf8'));
const db = await connectDB(config.dbUrl);
export { db };
No more (async () => { ... })() IIFE wrappers around our entry point. Just write the code.
The catch: top-level await makes a module’s evaluation async. If something imports this module, its import statement effectively waits for us. Usually fine, occasionally surprising.
The mental model
Use async/await by default. Use Promise.all for independent parallel work. Wrap legacy callback APIs with promisify. In ESM, lean on top-level await for startup. Callbacks aren’t dead — many event APIs (EventEmitter, streams) still use them — but for one-shot async results, promises and await won.