Callbacks and the Error-First Pattern

A callback is simply a function you hand to another function so it can be run later — often once some asynchronous work like reading a file, querying a database, or waiting on a timer has finished. Node.js was built around callbacks from day one: nearly every built-in asynchronous API (fs, net, dns, and more) still exposes a callback-based version alongside its modern promise-based counterpart. To make these callbacks predictable, Node standardized on the “error-first” (also called “Node-style”) calling convention: the callback’s first argument is always reserved for an error, and it is null when nothing went wrong. Understanding this pattern matters both because a lot of existing Node code and third-party packages still use it, and because it is the direct ancestor of the promises and async/await syntax covered in the next lessons.

Overview: How Callbacks Work in Node.js

A callback is just an ordinary JavaScript function passed as an argument to another function. Some callbacks run synchronously, in the middle of the call that received them — array.forEach(callback) and array.map(callback) are examples; the callback runs immediately, before forEach returns. Node’s asynchronous APIs work differently: the function you call (for example fs.readFile) returns immediately, usually with undefined, and your callback is invoked later, once the underlying operation actually finishes.

JavaScript itself runs on a single thread, so Node cannot simply block that thread while it waits on a disk read or a network response — the whole process would freeze. Instead, Node hands the operation off to libuv, the C library that implements Node’s event loop. Some operations, like network sockets, use OS-level asynchronous mechanisms directly (epoll on Linux, kqueue on macOS, IOCP on Windows) and never touch a thread. Others — most notably file system access and some crypto and dns functions — don’t have a good cross-platform async API at the OS level, so libuv simulates async behavior with a background thread pool (4 threads by default, tunable with the UV_THREADPOOL_SIZE environment variable). Either way, your JavaScript callback is only invoked once the event loop reaches the appropriate phase and finds your operation’s result waiting for it — never synchronously, never in the middle of the call that registered it.

API area Uses the thread pool? Notes
fs (file system) Yes Simulated async via libuv worker threads
dns.lookup() Yes dns.resolve*() variants use async c-ares instead, no thread pool
crypto.pbkdf2 / scrypt Yes CPU-heavy work is offloaded so it doesn’t block the loop
net / http / TCP sockets No OS-level async I/O, handled directly by the event loop
setTimeout / setInterval No Managed by libuv’s internal timer heap

Because a thrown exception can’t cross an asynchronous boundary — by the time your callback runs, the try/catch that originally called fs.readFile has long since finished executing — Node needed an explicit channel for reporting failure. That’s the entire reason the error-first convention exists: every Node-style callback receives the error, if any, as its first argument, so failure is just data passed through the same channel as success, rather than something that has to be caught.

Syntax

The general shape of a function that accepts and follows the error-first convention looks like this:

function asyncOperation(param1, param2, callback) {
  // ... perform the work ...

  if (somethingWentWrong) {
    return callback(new Error("Descriptive message"));
  }

  callback(null, resultValue);
}
Part Meaning
callback The last parameter, a function supplied by the caller and invoked once the work finishes
err (1st callback arg) An Error instance on failure, or null/undefined on success — always checked first
result, …rest Any further arguments carry the successful result(s); their meaning is undefined when err is set
return value of asyncOperation Usually undefined — the real output only exists inside the callback

Examples

Example 1: A minimal error-first callback

"use strict";

function greetLater(name, callback) {
  setTimeout(() => {
    callback(null, `Hello, ${name}!`);
  }, 1000);
}

greetLater("Ada", (err, message) => {
  if (err) {
    console.error("Something went wrong:", err.message);
    return;
  }
  console.log(message);
});

console.log("Waiting for greeting...");

Output:

Waiting for greeting...
Hello, Ada!

greetLater follows the pattern exactly: it takes a trailing callback parameter and, once its (simulated) async work is done, calls it with null for the error slot and the real result after it. Notice the log order — “Waiting for greeting…” always prints first, because greetLater returns immediately and the setTimeout callback only fires on a later turn of the event loop.

Example 2: Reading a real file with fs.readFile

const fs = require("node:fs");

fs.readFile("config.json", "utf8", (err, data) => {
  if (err) {
    if (err.code === "ENOENT") {
      console.error("config.json not found - using defaults");
    } else {
      console.error("Failed to read config.json:", err.message);
    }
    return;
  }

  const config = JSON.parse(data);
  console.log("Loaded config:", config);
});

console.log("Reading configuration file...");

Output (assuming config.json contains {"port": 3000, "host": "localhost"}):

Reading configuration file...
Loaded config: { port: 3000, host: 'localhost' }

This uses the real callback API of node:fs. The err.code property lets you distinguish specific failure reasons — "ENOENT" means the file doesn’t exist — rather than just checking whether err is truthy. The utf8 argument matters too: without it, data would be a raw Buffer instead of a string.

Example 3: Composing multiple callback steps

Assume a users.json file like this:

[
  { "name": "Grace", "active": true },
  { "name": "Alan", "active": false },
  { "name": "Ada", "active": true }
]
const fs = require("node:fs");

function readUsers(callback) {
  fs.readFile("users.json", "utf8", (err, data) => {
    if (err) return callback(err);
    callback(null, JSON.parse(data));
  });
}

function countActiveUsers(users, callback) {
  const activeCount = users.filter((user) => user.active).length;
  callback(null, activeCount);
}

function writeReport(count, callback) {
  const report = `Active users: ${count}\n`;
  fs.writeFile("report.txt", report, (err) => {
    if (err) return callback(err);
    callback(null, report);
  });
}

readUsers((err, users) => {
  if (err) return console.error("Failed to read users:", err.message);

  countActiveUsers(users, (err, count) => {
    if (err) return console.error("Failed to count users:", err.message);

    writeReport(count, (err, report) => {
      if (err) return console.error("Failed to write report:", err.message);
      console.log("Report written:");
      console.log(report);
    });
  });
});

Output:

Report written:
Active users: 2

Each step — reading, counting, writing — is a small named function that itself follows the error-first pattern, and each one checks its own err before calling its own callback. This is more realistic than a single flat operation: real programs usually chain several async steps together, and naming each step (rather than writing one big anonymous function inside another) already makes the nesting easier to follow — a technique worth knowing before you meet Promises, which solve this same problem more directly.

How It Works Step by Step

Walking through Example 2 in the order things actually happen:

  1. Node executes your file synchronously from top to bottom. It reaches the call to fs.readFile(...).
  2. fs.readFile registers the request with libuv and returns undefined immediately — it does not wait for the file to be read.
  3. libuv hands the actual read off to a worker thread in its thread pool, since file I/O isn’t natively async at the OS level on every platform.
  4. Node keeps running the rest of your synchronous code. This is why "Reading configuration file..." logs before "Loaded config:" — the second console.log call runs immediately, while the file read is still pending in the background.
  5. Once the worker thread finishes reading the file (successfully or with an error), libuv queues your callback to run on a future turn of the event loop, during its poll phase.
  6. When the event loop reaches that queued callback, Node invokes it with (err, data). Only at this point does JSON.parse(data) and the final console.log run.
  7. If the file didn’t exist, err would be set (with err.code === "ENOENT") and data would be undefined — which is exactly why checking err before touching data is not optional.

Common Mistakes

Mistake 1: Forgetting to check err first

Wrong:

const fs = require("node:fs");

fs.readFile("data.txt", "utf8", (err, data) => {
  console.log(data.toUpperCase());
});

If the read fails, data is undefined, so data.toUpperCase() throws a TypeError. Because this throw happens inside an async callback, no surrounding try/catch can see it — it becomes an uncaught exception and, by default, crashes the entire Node process.

Corrected:

const fs = require("node:fs");

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) {
    console.error("Could not read file:", err.message);
    return;
  }
  console.log(data.toUpperCase());
});

Mistake 2: Calling the callback more than once

Wrong:

function fetchWithTimeout(callback) {
  const timer = setTimeout(() => {
    callback(new Error("Request timed out"));
  }, 5000);

  doNetworkCall((err, result) => {
    clearTimeout(timer);
    if (err) {
      callback(err);
    }
    callback(null, result);
  });
}

Missing a return after callback(err) means that when an error occurs, callback still gets invoked a second time with (null, result) right after. Callers of an error-first function generally assume it fires exactly once — calling it twice can trigger duplicate database writes, double HTTP responses, or confusing bugs that only show up intermittently.

Corrected:

function fetchWithTimeout(callback) {
  let done = false;

  const timer = setTimeout(() => {
    if (done) return;
    done = true;
    callback(new Error("Request timed out"));
  }, 5000);

  doNetworkCall((err, result) => {
    if (done) return;
    done = true;
    clearTimeout(timer);
    if (err) return callback(err);
    callback(null, result);
  });
}

Mistake 3: Inconsistent timing (“releasing Zalgo”)

A callback-accepting function should always invoke its callback either synchronously or asynchronously — never sometimes one and sometimes the other, depending on which branch it takes. A function that calls its callback immediately when a value is cached, but via fs.readFile when it isn’t, can produce code whose execution order changes unpredictably depending on internal state the caller can’t see. When in doubt, wrap the synchronous branch in process.nextTick() or setImmediate() so the timing stays consistent.

Best Practices

  • Always check err before touching any other argument the callback received.
  • return immediately after calling the callback on an error path, so execution can’t fall through to the success path too.
  • Never invoke a callback more than once — guard with a boolean flag (as in the timeout example) whenever more than one code path could trigger it.
  • Keep callback invocation timing consistent: always synchronous or always asynchronous, never conditional on internal state.
  • Avoid deep nesting by extracting named functions for each step, as in Example 3 — and for anything beyond two or three steps, prefer Promises or async/await, covered in the next lessons.
  • Attach an error event listener to streams and EventEmitters — those errors are not delivered through a callback argument and will crash the process if unhandled.
  • For brand-new code, reach for node:fs/promises or util.promisify() instead of raw callbacks; the error-first pattern is essential to understand but rarely the best choice to write from scratch today.

Practice Exercises

1. Write a function divide(a, b, callback) that follows the error-first pattern: if b is 0, call callback with an Error (“Cannot divide by zero”); otherwise call it with null and the quotient.

2. Using the fs.readFile callback API, write a program that reads first.txt, and only after that succeeds reads second.txt, logging both contents in order and handling a possible error at each step separately.

3. Take a three-level nested callback pyramid (a database lookup, inside which a permissions check runs, inside which a log write happens) and refactor it into flat, named functions the way Example 3 does. Hint: each named function should take the previous step’s result plus its own callback.

Summary

  • A callback is a function passed to be invoked later, often once an asynchronous operation completes.
  • Node’s error-first convention reserves the callback’s first argument for an Error (or null), with successful results following it.
  • Async APIs like fs.readFile return immediately; libuv’s thread pool or OS-level mechanisms do the real work, and the callback fires later from the event loop.
  • Always check err before using any other argument, and return right after handling an error.
  • Never call a callback more than once, and keep its timing (sync vs async) consistent.
  • Deeply nested callbacks (“callback hell”) should be flattened into named functions or, better, rewritten with Promises/async-await.