Skip to content

Node.js Callbacks

Callbacks are the original way Node.js handled asynchronous results, and they still appear throughout Node's core APIs and older libraries. This lesson explains the error-first convention and its well-known pitfalls.

What Is a Callback?

A callback is simply a function passed as an argument to another function, to be called later once some work finishes. Node.js's core APIs standardized on the "error-first" callback convention: the callback's first parameter is always an error (or null if there wasn't one), and the second parameter is the result.

This convention lets every async Node.js API be used consistently: check err first, and only trust data if err is falsy.

import fs from 'node:fs';

fs.readFile('./config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read file:', err.message);
    return;
  }

  console.log('File contents:', data);
});

Always check err first and return early if it's truthy, this prevents accidentally using data when the operation actually failed.

The Error-First Callback Pattern

function doAsyncWork(callback) {
  // ... some async operation ...
  callback(null, result);   // success
  // or
  callback(error);           // failure
}
  • The first parameter is always reserved for an error; pass null when there isn't one.
  • Callers must check the error parameter before trusting any subsequent arguments.
  • This convention predates promises and is still used by many Node.js core APIs and libraries.
  • You can wrap callback-based functions into promises using util.promisify().

Callbacks Cheatsheet

Patterns and gotchas around Node.js-style callbacks.

Concept Example Notes
Error-first signature (err, data) => {} Standard Node.js convention
Check error first if (err) return handleError(err); Always guard before using data
Nested callbacks a(() => b(() => c())) Leads to "callback hell"
Promisify util.promisify(fs.readFile) Converts a callback API into a promise-based one
Named functions Extract nested callbacks into named functions Improves readability significantly

Callback Hell

When multiple asynchronous steps depend on each other, nesting callbacks inside callbacks quickly produces deeply indented, hard-to-follow code known as "callback hell" or the "pyramid of doom". Each new step adds another level of nesting and another error check.

fs.readFile('a.txt', 'utf8', (err, a) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', 'utf8', (err, b) => {
    if (err) return console.error(err);
    fs.writeFile('combined.txt', a + b, (err) => {
      if (err) return console.error(err);
      console.log('Done!');
    });
  });
});

This is exactly the problem promises (and later async/await) were designed to solve, see the next two lessons.

Converting Callbacks to Promises

Node.js's util.promisify() wraps any error-first callback function into one that returns a promise instead, letting you use modern async/await syntax with older callback-based APIs or libraries.

import { promisify } from 'node:util';
import fs from 'node:fs';

const readFile = promisify(fs.readFile);

const data = await readFile('./config.json', 'utf8');
console.log(data);

Most core Node.js modules now ship a /promises variant directly (like node:fs/promises), making manual promisify() less necessary than it used to be.

Common Mistakes

  • Forgetting to check the error parameter before using the result.
  • Calling a callback more than once, some APIs expect it to be invoked exactly once.
  • Nesting many callbacks instead of extracting named functions or switching to promises.
  • Confusing callback-style APIs with promise-style APIs and mixing .then() onto a function that expects a callback argument.

Key Takeaways

  • A callback is a function passed to be invoked later, once an async operation completes.
  • Node.js's convention is "error-first": (err, data) => {}.
  • Deeply nested callbacks lead to hard-to-read "callback hell".
  • util.promisify() (or a library's built-in /promises API) converts callbacks into promises.

Pro Tip

When you inherit an old callback-based codebase, promisify the specific functions you need at the boundary, then write all new logic with async/await. You don't need to rewrite everything at once to get a cleaner style going forward.