Node.js Interview Prep
Asynchronous Patterns

Callbacks & the Error-First Convention

Callbacks & the Error-First Convention

LinkedIn Hook

"Why does every Node.js callback start with (err, result) instead of just (result)?"

It looks weird the first time you see it. Most languages put the result first and throw exceptions for errors. Node flipped the script — and that single decision shaped the entire async ecosystem.

The reason is simple but powerful. In synchronous code, a try/catch catches errors automatically. In asynchronous code, the function that started the work has already returned by the time the error happens. There is no stack to throw into. So Node's authors made a rule: every callback receives the error as its first argument. You cannot use the result without first stepping over the error slot. The convention forces you to think about failure.

But callbacks have a dark side too. Nest a few of them and you get the pyramid of doom — code that drifts diagonally across the screen, impossible to read, harder to debug. That's why util.promisify and async/await exist.

In Lesson 5.1, I break down why error-first callbacks were designed this way, when they still matter in 2026, and how to convert them into modern promise-based code.

Read the full lesson -> [link]

#NodeJS #JavaScript #AsyncProgramming #BackendDevelopment #InterviewPrep


Callbacks & the Error-First Convention thumbnail


What You'll Learn

  • Why Node.js uses the (err, result) error-first callback convention
  • How to read and write classic callback-based APIs like fs.readFile
  • What callback hell (the pyramid of doom) looks like and why it hurts
  • Named functions vs inline functions for flattening nested callbacks
  • How to convert any error-first callback into a promise with util.promisify
  • When callbacks still matter in modern Node.js (legacy APIs, hot paths, EventEmitter)
  • The two most dangerous callback bugs: double-calls and forgotten returns

The Waiter Analogy — Why Errors Come First

Imagine you order food at a busy restaurant. The waiter walks away, the kitchen does its work, and a few minutes later the waiter returns to your table. There are exactly two things he can say:

  • "Here's your food."
  • "Sorry, we ran out of salmon — here's why."

A good waiter never just drops a plate without acknowledging a problem first. He says the bad news up front, then hands over the result. If he forgot to mention the kitchen was on fire, you might happily eat smoke-flavored pasta and never know something went wrong.

That is exactly the contract of an error-first callback in Node.js. The function you pass in is the waiter. When the async work finishes, Node "comes back to your table" and calls your function with two arguments: the error (the bad news) and the result (the food). The error comes first because you must check it before you touch the result. If you skip the check, you risk operating on garbage data — eating smoke pasta.

+---------------------------------------------------------------+
|           THE ERROR-FIRST CALLBACK CONTRACT                   |
+---------------------------------------------------------------+
|                                                                |
|  You call:    fs.readFile('a.txt', callback)                   |
|                                                                |
|  Node runs the I/O on the thread pool...                       |
|                                                                |
|  Node calls back EXACTLY ONE of these two ways:                |
|                                                                |
|    SUCCESS  ->  callback(null, data)                           |
|                  ^^^^   ^^^^                                    |
|                  no err  the result                             |
|                                                                |
|    FAILURE  ->  callback(err, undefined)                       |
|                  ^^^      ^^^^^^^^^                             |
|                  Error    no result                             |
|                                                                |
|  RULE: check `err` FIRST. Always. No exceptions.               |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A waiter in a black suit walking from a kitchen to a table. Two thought bubbles above the waiter: LEFT bubble (amber #ffb020) shows 'err: ENOENT' with an 'X', RIGHT bubble (Node green #68a063) shows 'result: data' with a checkmark. Below: the function signature (err, result) => in white monospace. The err slot is highlighted amber, the result slot is highlighted green."


The Classic Example — fs.readFile

The Node core API that introduced most developers to error-first callbacks is fs.readFile. It is the canonical example of the convention.

// Import the file system module
const fs = require('fs');

// Read a file asynchronously
// The callback receives (err, data) — error first, result second
fs.readFile('config.json', 'utf8', (err, data) => {
  // STEP 1: Always check the error first
  if (err) {
    // The file did not exist, permissions failed, disk error, etc.
    console.error('Failed to read config:', err.message);
    return; // CRITICAL: return so we do not fall through to the success path
  }

  // STEP 2: Only now is it safe to use `data`
  // Without the early return above, `data` could be undefined here
  const config = JSON.parse(data);
  console.log('Loaded config:', config);
});

// This line runs IMMEDIATELY, before the file is read
// readFile is non-blocking — it returns right away
console.log('readFile was queued, continuing...');

What the output looks like:

readFile was queued, continuing...
Loaded config: { port: 3000, env: 'development' }

Notice the order. The "continuing..." line prints first because fs.readFile does not block — it queues the work on the libuv thread pool and returns immediately. The callback fires later, after the file is read off disk.

Why error-first instead of result-first?

Three reasons drove the design decision:

  1. Consistency. Every async function in Node uses the same shape. You never have to remember which library puts the error where. (err, ...) always.
  2. Forced awareness. The error sits in the most prominent position — the first parameter. You cannot ignore it visually the way you might ignore a .catch() tacked onto the end of a chain.
  3. No throwing across async boundaries. A traditional throw cannot cross an async gap because the original call stack is gone. Passing the error as data is the only safe way to deliver it.

Callback Hell — The Pyramid of Doom

Callbacks work fine for one operation. The pain begins when you need to chain several async operations together. Each step nests inside the previous one's callback, and your code drifts diagonally across the screen.

const fs = require('fs');

// Read a config, then read the file it points to,
// then write a transformed version, then read it back to verify
fs.readFile('config.json', 'utf8', (err, configData) => {
  if (err) {
    console.error('Step 1 failed:', err);
    return;
  }
  const config = JSON.parse(configData);

  fs.readFile(config.inputPath, 'utf8', (err, inputData) => {
    if (err) {
      console.error('Step 2 failed:', err);
      return;
    }
    const transformed = inputData.toUpperCase();

    fs.writeFile(config.outputPath, transformed, (err) => {
      if (err) {
        console.error('Step 3 failed:', err);
        return;
      }

      fs.readFile(config.outputPath, 'utf8', (err, verifyData) => {
        if (err) {
          console.error('Step 4 failed:', err);
          return;
        }

        // We are now FOUR levels deep
        // Notice how the closing braces stack up below like a staircase
        console.log('Wrote and verified:', verifyData.length, 'bytes');
      });
    });
  });
});
+---------------------------------------------------------------+
|           THE PYRAMID OF DOOM                                 |
+---------------------------------------------------------------+
|                                                                |
|  readFile(..., (err, data) => {                                |
|    if (err) return;                                            |
|    readFile(..., (err, data) => {                              |
|      if (err) return;                                          |
|        writeFile(..., (err) => {                               |
|          if (err) return;                                      |
|            readFile(..., (err, data) => {                      |
|              if (err) return;                                  |
|                doSomething();                                  |
|              });                                               |
|            });                                                 |
|          });                                                   |
|        });                                                     |
|                                                                |
|  Indentation grows. Error handling repeats. Logic hides.       |
|  Refactoring is painful. Bugs love this shape.                 |
|                                                                |
+---------------------------------------------------------------+

Flattening with named functions

One pre-promise way to fight the pyramid is to extract each step into a named function. This trades nesting for readability.

const fs = require('fs');

// Each step is a named, top-level function
function loadConfig(done) {
  fs.readFile('config.json', 'utf8', (err, data) => {
    if (err) return done(err);
    done(null, JSON.parse(data));
  });
}

function readInput(config, done) {
  fs.readFile(config.inputPath, 'utf8', (err, data) => {
    if (err) return done(err);
    done(null, { config, data });
  });
}

function writeOutput({ config, data }, done) {
  const transformed = data.toUpperCase();
  fs.writeFile(config.outputPath, transformed, (err) => {
    if (err) return done(err);
    done(null, config.outputPath);
  });
}

// Wire them together — still nested, but each step is testable in isolation
loadConfig((err, config) => {
  if (err) return console.error(err);
  readInput(config, (err, payload) => {
    if (err) return console.error(err);
    writeOutput(payload, (err, path) => {
      if (err) return console.error(err);
      console.log('Wrote', path);
    });
  });
});

This is better — each function has a single responsibility and a single error path — but it is still nested. Promises and async/await (Lesson 5.2) finally solve the structural problem.


util.promisify — Bridging Callbacks to Promises

Modern Node.js code prefers promises and async/await. But the Node core library and many older packages still expose error-first callback APIs. The util.promisify helper converts any function that follows the convention into one that returns a promise.

const fs = require('fs');
const util = require('util');

// Wrap the callback-based readFile and get a promise-based version
const readFileAsync = util.promisify(fs.readFile);

// Now we can use it with async/await — no callbacks, no nesting
async function loadConfig() {
  try {
    // The promisified version returns the result directly
    // Errors become thrown exceptions, caught by try/catch
    const data = await readFileAsync('config.json', 'utf8');
    return JSON.parse(data);
  } catch (err) {
    console.error('Failed to load config:', err.message);
    throw err;
  }
}

loadConfig().then(cfg => console.log(cfg));

The earlier four-level pyramid collapses into a flat sequence:

const fs = require('fs');
const util = require('util');

const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);

async function processFile() {
  // Each await replaces a nested callback
  // Errors at any step bubble to the single catch block at the bottom
  const configData = await readFile('config.json', 'utf8');
  const config = JSON.parse(configData);

  const input = await readFile(config.inputPath, 'utf8');
  const transformed = input.toUpperCase();

  await writeFile(config.outputPath, transformed);
  const verify = await readFile(config.outputPath, 'utf8');

  console.log('Wrote and verified:', verify.length, 'bytes');
}

processFile().catch(err => console.error('Pipeline failed:', err));

How util.promisify works under the hood. It returns a new function that wraps your callback API. When called, the wrapper creates a promise and passes a synthetic callback (err, result) to the original function. If err is truthy the promise rejects; otherwise it resolves with result. That's it — about 15 lines of code, but it bridges two entire async eras.

Note: Node also ships fs.promises (and most core modules have a .promises namespace) which gives you promise-based APIs without needing util.promisify at all. Use those when available; reach for util.promisify for third-party packages that haven't been promisified yet.


Writing Your Own Error-First Function

When you write an async function with a callback API, follow the convention exactly so it composes with util.promisify and the rest of the ecosystem.

// A custom async function that loads a user from a fake database
function findUser(id, callback) {
  // Simulate async work with setTimeout
  setTimeout(() => {
    // Validate input — invalid id is an error
    if (typeof id !== 'number') {
      // Pass an Error object as the FIRST argument
      // Always use real Error instances, never strings
      return callback(new Error('id must be a number'));
    }

    // Simulate "not found"
    if (id < 1) {
      return callback(new Error('User not found'));
    }

    // Success path: error slot is null, result slot has the data
    const user = { id, name: 'Alice', email: 'alice@example.com' };
    callback(null, user);
  }, 50);
}

// Consume it the standard way
findUser(42, (err, user) => {
  if (err) {
    console.error('Lookup failed:', err.message);
    return;
  }
  console.log('Found:', user);
});

// Because we followed the convention exactly, promisify works for free
const util = require('util');
const findUserAsync = util.promisify(findUser);

(async () => {
  const user = await findUserAsync(42);
  console.log('Promisified result:', user);
})();

The rules for writing error-first callbacks:

  1. The callback is always the last parameter of your function.
  2. Call it with (err) or (err, result) — error first, always.
  3. Pass null (not undefined) when there is no error.
  4. Always pass real Error instances, not strings or plain objects.
  5. Call the callback exactly once per invocation. Never zero times. Never twice.
  6. Always return after calling the callback in an early-exit path.

When Callbacks Still Matter

Promises and async/await are the default in 2026, but callbacks have not gone away. Three places you will still see them:

1. Legacy and third-party APIs. Plenty of npm packages were written before promises were standard. You either wrap them with util.promisify or use them directly. Knowing the convention is non-negotiable.

2. Hot paths where allocation matters. A promise creates at least one extra object on the heap per call. In a tight loop processing millions of items, that adds GC pressure. Some performance-critical libraries (like database drivers) still expose callback APIs as the lowest-overhead path. The savings are small but real, and they matter in hot loops.

3. EventEmitter and stream callbacks. Events are not one-shot — they fire many times. Promises resolve exactly once, so they cannot model a stream of data events or an emitter that fires error repeatedly. Anywhere you see .on('data', handler) or .on('error', handler), you are using a callback. This pattern is foundational and is not going anywhere.

+---------------------------------------------------------------+
|           CALLBACK vs PROMISE — WHEN TO USE WHICH             |
+---------------------------------------------------------------+
|                                                                |
|  USE CALLBACKS WHEN:                                           |
|  - Working with EventEmitter (.on, .once)                      |
|  - Wrapping a legacy library that has not been promisified     |
|  - Inside hot loops where every allocation counts              |
|  - Implementing low-level libraries others will promisify      |
|                                                                |
|  USE PROMISES / async-await WHEN:                              |
|  - Writing application code (almost always)                    |
|  - You need to compose multiple async steps                    |
|  - You want try/catch error handling                           |
|  - You need Promise.all, Promise.race, Promise.allSettled      |
|                                                                |
+---------------------------------------------------------------+

Common Mistakes

1. Calling the callback twice. A subtle but devastating bug. The caller assumes the callback fires exactly once. If you forget a return after an error, you may call it once with an error and then again with a success — and downstream code will run twice, fire duplicate HTTP responses, double-bill a customer, or corrupt state. Always return callback(err) in error branches.

2. Forgetting to return after the callback.

// WRONG — falls through after error
fs.readFile('a.txt', (err, data) => {
  if (err) callback(err);            // missing return!
  callback(null, data.toString());   // runs even on error, crashes on undefined
});

// RIGHT
fs.readFile('a.txt', (err, data) => {
  if (err) return callback(err);
  callback(null, data.toString());
});

3. Throwing inside an async callback. A throw inside a callback cannot be caught by the function that scheduled the async work — its stack is long gone. The throw becomes an unhandled exception and crashes the process. Pass errors via the callback's first argument instead of throwing.

4. Passing strings instead of Error objects. callback('something broke') works but loses the stack trace, the type, and the .message property that downstream code expects. Always pass new Error('something broke').

5. Mixing callbacks and promises in the same function. Pick one. A function that sometimes returns a promise and sometimes calls a callback is impossible to reason about and impossible to promisify cleanly.

6. Forgetting that synchronous code runs first. Beginners write const data = fs.readFile(...) and expect data to hold the file contents. It does not — readFile is non-blocking and returns undefined. The data only arrives later, in the callback.


Interview Questions

1. "What is the error-first callback convention and why did Node.js adopt it?"

The error-first convention says that every async callback in Node.js takes the error as its first argument and the result as its second: (err, result) => .... On success the error slot is null; on failure the result slot is undefined and err is an Error instance. Node adopted this for three reasons. First, consistency — every callback in the ecosystem has the same shape, so you never have to remember where the error lives. Second, forced awareness — the error sits in the most visible position, making it harder to ignore than a tacked-on .catch(). Third, no throwing across async boundaries — by the time an async operation finishes, the original call stack is gone, so you cannot throw an error back to the caller. Passing it as data is the only safe way to deliver it.

2. "What is callback hell and how do you avoid it?"

Callback hell, also called the pyramid of doom, happens when you chain multiple async operations and each one nests inside the previous one's callback. The code drifts diagonally across the screen, indentation grows, error handling repeats at every level, and the actual logic gets buried. There are three main ways to fight it. The pre-promise approach is to extract each step into a named function so each layer is shallow and testable. The modern approach is to convert callbacks to promises with util.promisify and use async/await, which flattens the nesting into a linear sequence with a single try/catch. The third option is control-flow libraries like the old async package, which provided helpers like async.waterfall for sequential steps — though these are largely obsolete now that async/await exists.

3. "How does util.promisify work, and when would you not use it?"

util.promisify takes a function that follows the error-first callback convention and returns a new function that returns a promise. Internally, the wrapper creates a promise, calls your original function with a synthetic callback (err, result), and either rejects with err or resolves with result. You would not use it in three situations. First, when the function does not follow the convention exactly — for example, callbacks that take multiple result arguments or put the error somewhere other than position zero. Second, when a promise-based version already exists, like fs.promises.readFile — use the native one. Third, for EventEmitter-style APIs that fire callbacks many times, since promises resolve only once and cannot represent a stream of events.

4. "Why is calling a callback twice dangerous, and how do you prevent it?"

The contract of a callback is "exactly once." Downstream code assumes that — it might send an HTTP response, write to a database, or release a lock. If you call the callback twice, all of that runs twice: a duplicate response triggers ERR_HTTP_HEADERS_SENT, a database write becomes a duplicate row, a lock gets released that was never re-acquired. The most common cause is forgetting to return after handling an error: if (err) callback(err); callback(null, data); runs both branches. The fix is always return callback(err); in early-exit paths. Defensive code can wrap the callback in a guard that flips a called flag on first use, but the real solution is rigorous early returns and code review.

5. "Are callbacks obsolete in modern Node.js? When would you still use them?"

No, they are not obsolete — they are foundational. Three places you will still encounter them. Legacy APIs: many third-party packages predate promises and you either wrap them with util.promisify or call them directly. Performance-critical hot paths: a promise allocates extra heap objects per call, and in tight loops processing millions of items those allocations add measurable GC pressure, so some database drivers and parsers still expose callback APIs as the low-overhead path. EventEmitters and streams: events fire many times, but promises resolve exactly once, so anything using .on('data', ...) or .on('error', ...) is fundamentally callback-based. Callbacks are not the default for application code anymore, but knowing them is essential for reading existing code and working with the parts of Node where they remain the right tool.


Quick Reference — Callback Cheat Sheet

+---------------------------------------------------------------+
|           ERROR-FIRST CALLBACK CHEAT SHEET                    |
+---------------------------------------------------------------+
|                                                                |
|  THE SHAPE:                                                    |
|  function asyncOp(arg1, arg2, callback) {                      |
|    // ... do work ...                                          |
|    if (failed) return callback(new Error('msg'));              |
|    callback(null, result);                                     |
|  }                                                             |
|                                                                |
|  CALLING:                                                      |
|  asyncOp('a', 'b', (err, result) => {                          |
|    if (err) return handle(err);                                |
|    use(result);                                                |
|  });                                                           |
|                                                                |
|  PROMISIFY:                                                    |
|  const util = require('util');                                 |
|  const asyncOpP = util.promisify(asyncOp);                     |
|  const result = await asyncOpP('a', 'b');                      |
|                                                                |
|  CORE MODULE PROMISES:                                         |
|  const fs = require('fs').promises;                            |
|  const data = await fs.readFile('a.txt', 'utf8');              |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           THE SIX RULES                                       |
+---------------------------------------------------------------+
|                                                                |
|  1. Callback is the LAST parameter                             |
|  2. Error is the FIRST argument to the callback                |
|  3. Pass null (not undefined) when there is no error           |
|  4. Pass real Error instances, never strings                   |
|  5. Call the callback EXACTLY ONCE per invocation              |
|  6. Always `return` after calling cb in an early-exit path     |
|                                                                |
+---------------------------------------------------------------+
AspectCallbackPromise / async-await
Error deliveryFirst argument.catch() or try/catch
CompositionNested or named functionsLinear await sequence
Multiple resultsYes (multi-arg)One value (use object/tuple)
Fires multiple timesYes (events, streams)No (resolves once)
Allocation overheadMinimalSlightly higher (heap object)
Modern defaultNoYes
Still required forEventEmitter, legacy, hot loopsMost application code

Prev: Lesson 4.4 -- Streams in Real Applications Next: Lesson 5.2 -- Promises & async/await


This is Lesson 5.1 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page