Callbacks, Promises & async/await in Node

intermediate nodejs async promises esm

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.

Callbacks
Original Node style. Error-first. Hard to compose.
Promises
Chainable. then/catch. Composable.
async/await
Reads like sync. try/catch works. Default in 2025.

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.