| |

Node.js 13 🟢 Legacy Callback Patterns and Error-First Conventions

Callbacks are the original asynchronous pattern in Node.js. Before promises and async/await, every asynchronous operation was expressed by passing a function that would be invoked when the operation completed. The convention that emerged is error-first: the callback receives an error as its first argument and the result as its second. If the error is null or undefined, the operation succeeded; if it is set, the result is undefined and the error describes what went wrong. This convention is still present throughout the Node.js core APIs, and understanding it is necessary for reading legacy code, wrapping older libraries, and using the parts of the standard library that have not been promisified.

Callbacks are not obsolete, but they are no longer the default. The shift to promises was driven by the problems that callbacks create at scale: nested structure, repeated error checks, and difficulty composing operations. Understanding those problems explains why promises exist and how the error-first convention shaped the design of the entire ecosystem. It also explains why so much existing code still uses callbacks and why util.promisify exists to bridge the two worlds.

This chapter covers the callback pattern, the error-first convention, the structure of callback-based code, the problems of nesting and error handling, the conventions for synchronous versus asynchronous callbacks, and the patterns for wrapping callback APIs with promises.

Key point: A callback is a function passed to an asynchronous operation and invoked when it completes. The error-first convention places the error as the first argument and the result as the second. If the error is set, the operation failed; if it is null, the result is valid. The util.promisify function converts an error-first callback API into a promise-returning one.


Why callbacks and error-first conventions exist

The asynchronous-result problem. An asynchronous operation cannot return its result directly because the result is not available when the call returns. The callback is the mechanism for delivering the result later: the function is passed to the operation and invoked when the work is done.

The error-propagation problem. Synchronous errors propagate through throw and try/catch. Asynchronous errors cannot be thrown because there is no stack to catch them at the point of failure. The error-first convention solves this by passing the error as the first argument to the callback, making it an explicit parameter that the callback must check.

The convention problem. Without a convention, every library would design its own callback signature. The error-first pattern became the standard because it is simple, uniform, and works for every operation. The Node.js core APIs adopted it, and the ecosystem followed.

The nesting problem. Sequential asynchronous operations with callbacks nest. Each step adds a level of indentation, and error handling is repeated at every level. This is the “callback hell” that motivated promises.

The composition problem. Callbacks are difficult to compose. Running operations in parallel and combining results requires manual bookkeeping. Promises provide combinators that callbacks lack.


a. The callback pattern

A callback is a function passed as an argument to another function, to be invoked when an asynchronous operation completes.

const fs = require('fs');

fs.readFile('/etc/hostname', 'utf8', (err, data) => {
  if (err) {
    console.error('failed:', err);
    return;
  }
  console.log('data:', data.trim());
});

The third argument to fs.readFile is the callback. It receives an error and the file’s contents. The function returns immediately, and the callback runs later when the read completes.

The callback pattern is used throughout the Node.js core APIs: fs.readFile, fs.writeFile, net.Socket, http.Server, dns.lookup, and many others.

A callback can be a named function or an inline arrow function:

function handleResult(err, data) {
  if (err) throw err;
  console.log(data);
}

fs.readFile('file.txt', 'utf8', handleResult);

b. The error-first convention

The error-first convention is a specific callback signature: the first argument is the error, and the remaining arguments are the results.

function callback(err, result) {
  if (err) {
    // handle the error
    return;
  }
  // use the result
}

The rules are:

ConditionMeaning
err is null or undefinedThe operation succeeded; result is valid
err is setThe operation failed; result is undefined

The check for the error is always the first thing the callback does. If the error is set, the callback handles it and returns. If it is not, the callback proceeds to use the result.

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error:', err.message);
    return;
  }
  console.log('Content:', data);
});

For an operation that has only an error and no result, the callback receives only the error:

fs.writeFile('file.txt', 'content', (err) => {
  if (err) throw err;
  console.log('written');
});

For an operation that can produce multiple results, the callback receives the error followed by the results:

dns.lookup('example.com', (err, address, family) => {
  if (err) throw err;
  console.log(address, family);
});

The convention is consistent: the error is always first, and the success values follow.


c. The structure of callback code

Callback-based code follows a recognizable structure: each asynchronous operation takes a callback, and the callback checks the error before proceeding.

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

  let config;
  try {
    config = JSON.parse(data);
  } catch (parseErr) {
    console.error('Invalid JSON:', parseErr);
    return;
  }

  connectToDatabase(config.dbUrl, (err, db) => {
    if (err) {
      console.error('Failed to connect:', err);
      return;
    }

    queryDatabase(db, 'SELECT * FROM users', (err, users) => {
      if (err) {
        console.error('Query failed:', err);
        return;
      }

      console.log('Users:', users);
    });
  });
});

Each step nests one level deeper, and the error check repeats at every level. This is the structure that becomes difficult to read as the number of steps grows.

The nesting is not the only problem. Error handling is duplicated, and the indentation makes it harder to see the flow. A common style is to extract each callback into a named function, which flattens the structure but scatters the logic:

function onConfigRead(err, data) {
  if (err) return console.error('Config read failed:', err);
  const config = JSON.parse(data);
  connectToDatabase(config.dbUrl, onConnected);
}

function onConnected(err, db) {
  if (err) return console.error('Connection failed:', err);
  queryDatabase(db, 'SELECT * FROM users', onUsersQueried);
}

function onUsersQueried(err, users) {
  if (err) return console.error('Query failed:', err);
  console.log('Users:', users);
}

fs.readFile('config.json', 'utf8', onConfigRead);

The named functions remove the nesting but make the flow harder to follow. Neither style is fully satisfactory, which is why promises became the default.


d. Synchronous versus asynchronous callbacks

A callback can be invoked synchronously or asynchronously. The distinction matters because it changes when the callback runs relative to the code that follows the call.

// Asynchronous callback
fs.readFile('file.txt', 'utf8', (err, data) => {
  console.log('callback');
});
console.log('after call');

// Output:
// after call
// callback

The asynchronous callback runs after the current synchronous code finishes. The after call line prints first.

A synchronous callback runs before the function returns:

function each(array, callback) {
  for (const item of array) {
    callback(item);  // called synchronously
  }
}

The Array.prototype.forEach method is synchronous; the callback runs immediately for each element.

The error-first convention applies to asynchronous callbacks, but the rule that a callback should never be called synchronously is a separate design principle. An API that sometimes calls the callback synchronously and sometimes asynchronously is unpredictable. The convention is to always call the callback asynchronously, either by deferring with process.nextTick or by ensuring the operation is inherently asynchronous.

function safeAsyncCall(callback) {
  if (cached) {
    process.nextTick(() => callback(null, cached));
    return;
  }
  doRealWork(callback);
}

The process.nextTick defers the callback to the end of the current operation, so it runs after the caller’s synchronous code completes. This makes the API consistently asynchronous, which is easier to reason about and avoids Zalgo.


e. Wrapping callback APIs with promises

The util.promisify function converts an error-first callback function into a promise-returning one.

const { promisify } = require('util');
const fs = require('fs');

const readFile = promisify(fs.readFile);

async function main() {
  const data = await readFile('file.txt', 'utf8');
  console.log(data);
}

The converted function takes the same arguments minus the callback, and returns a promise that fulfills with the result or rejects with the error.

The conversion works when the function follows the error-first convention exactly: the callback is the last argument, and it receives the error first. This is the case for the Node.js core APIs.

For a custom function, promisify works if the function is written correctly:

function fetchData(id, callback) {
  setTimeout(() => {
    if (id < 0) {
      callback(new Error('invalid id'));
      return;
    }
    callback(null, { id, name: 'Item ' + id });
  }, 100);
}

const fetchDataAsync = promisify(fetchData);

async function main() {
  try {
    const data = await fetchDataAsync(1);
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

If the function does not follow the convention, a manual wrapper is needed:

function fetchDataAsync(id) {
  return new Promise((resolve, reject) => {
    fetchData(id, (err, data) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(data);
    });
  });
}

The manual wrapper is the fallback for functions with a different callback signature or with multiple callback arguments.


f. When callbacks are still appropriate

Callbacks are still used in several places, and understanding the convention is necessary even in promise-based code.

Streams and events. The EventEmitter pattern uses callbacks for event handlers, and streams use them for data, end, and error events.

stream.on('data', (chunk) => {
  process(chunk);
});

stream.on('error', (err) => {
  console.error(err);
});

Server handlers. The http.createServer and net.createServer methods take a callback for each connection.

const server = http.createServer((req, res) => {
  res.end('hello');
});

Middleware. Express middleware uses callbacks with a next function, which is a different convention from error-first but still callback-based.

One-off operations. For a simple asynchronous call, a callback is sometimes simpler than wrapping in a promise, though most new code uses promises for consistency.

Legacy libraries. Many libraries were written before promises and still use error-first callbacks. util.promisify makes them usable in async code.

The general guidance is to use promises for new code and to understand callbacks for reading and integrating existing code.


Complete Example Session

// ============================================
// PART 1: BASIC CALLBACK
// ============================================
const fs = require('fs');

fs.readFile('/etc/hostname', 'utf8', (err, data) => {
  if (err) {
    console.error('failed:', err);
    return;
  }
  console.log('data:', data.trim());
});
// ============================================
// PART 2: ERROR-FIRST CONVENTION
// ============================================
function divide(a, b, callback) {
  if (b === 0) {
    callback(new Error('division by zero'));
    return;
  }
  callback(null, a / b);
}

divide(10, 2, (err, result) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result);  // 5
});
// ============================================
// PART 3: CALLBACK WITH MULTIPLE RESULTS
// ============================================
const dns = require('dns');

dns.lookup('example.com', (err, address, family) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(address, family);
});
// ============================================
// PART 4: NESTED CALLBACKS
// ============================================
fs.readFile('a.txt', 'utf8', (err, dataA) => {
  if (err) return console.error(err);

  fs.readFile('b.txt', 'utf8', (err, dataB) => {
    if (err) return console.error(err);

    fs.writeFile('c.txt', dataA + dataB, (err) => {
      if (err) return console.error(err);
      console.log('done');
    });
  });
});
// ============================================
// PART 5: SYNCHRONOUS CALLBACK (forEach)
// ============================================
[1, 2, 3].forEach((n) => {
  console.log(n);  // runs immediately
});
// ============================================
// PART 6: ASYNCHRONOUS CALLBACK
// ============================================
setTimeout(() => {
  console.log('later');
}, 0);
console.log('now');

// Output:
// now
// later
// ============================================
// PART 7: CONSISTENT ASYNCHRONOUS CALLBACK
// ============================================
const cache = new Map();

function getItem(key, callback) {
  if (cache.has(key)) {
    process.nextTick(() => callback(null, cache.get(key)));
    return;
  }
  // simulate async fetch
  setTimeout(() => {
    const value = 'value for ' + key;
    cache.set(key, value);
    callback(null, value);
  }, 10);
}
// ============================================
// PART 8: PROMISIFY
// ============================================
const { promisify } = require('util');
const readFile = promisify(require('fs').readFile);

async function main() {
  try {
    const data = await readFile('file.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}
// ============================================
// PART 9: MANUAL PROMISE WRAPPER
// ============================================
function fetchDataAsync(id) {
  return new Promise((resolve, reject) => {
    fetchData(id, (err, data) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(data);
    });
  });
}
// ============================================
// PART 10: STREAMS USE CALLBACKS
// ============================================
const stream = fs.createReadStream('large.txt', 'utf8');

stream.on('data', (chunk) => {
  process.stdout.write('.');
});

stream.on('end', () => {
  console.log('\ndone');
});

stream.on('error', (err) => {
  console.error('stream error:', err);
});

These ten parts cover the basic callback, the error-first convention, multiple results, nested callbacks, synchronous callbacks, asynchronous callbacks, consistent asynchronous callbacks, util.promisify, a manual promise wrapper, and streams.


Quick Reference

Error-First Signature

ArgumentMeaning
errError object or null
resultThe result (if no error)

Convention Rules

RuleDetail
Error is firstAlways the first parameter
Error is null on successCheck with if (err)
Return after errorDo not use the result if err is set
Callback is last argumentConsistent ordering
Call asynchronouslyAlways, for consistency

Synchronous vs Asynchronous

TypeWhen it runs
SynchronousBefore the function returns
AsynchronousAfter the current operation completes
ConsistentAlways asynchronous, even for cached results

Promisify

AspectDetail
Functionutil.promisify
InputError-first callback function
OutputPromise-returning function
Works whenCallback is last argument, error first
FallbackManual new Promise wrapper

Callback APIs in Core

APICallback
fs.readFile(err, data)
fs.writeFile(err)
dns.lookup(err, address, family)
http.createServer(req, res)
stream.on('data')(chunk)

Best Practices

✅ Do This:

// Check the error first
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

// Call callbacks asynchronously
process.nextTick(() => callback(null, cached));

// Use util.promisify for legacy APIs
const readFile = promisify(require('fs').readFile);

// Return after handling the error
if (err) return console.error(err);

// Use named functions to reduce nesting
fs.readFile('a.txt', 'utf8', onRead);

❌ Don’t Do This:

// Ignore the error
fs.readFile('file.txt', 'utf8', (err, data) => {
  console.log(data);  // ❌ err ignored
});

// Use the result after an error
if (err) console.error(err);
console.log(data);  // ❌ data may be undefined

// Call the callback synchronously sometimes
if (cached) callback(null, cached);  // ❌ inconsistent

// Nest deeply
fs.readFile('a', (e, a) => {
  fs.readFile('b', (e, b) => {  // ❌ use promises
    fs.readFile('c', (e, c) => {
      // ...
    });
  });
});

Common Pitfalls

PitfallWhy It HappensFix
Ignored errorNo checkAdd if (err)
Result used after errorMissing returnReturn after handling
Inconsistent callback timingCached path calls syncUse process.nextTick
Deep nestingSequential operationsUse promises or named functions
Double callbackCalled twiceGuard against multiple calls
Lost errorThrown in callbackUse error-first convention
Promisify failsNon-standard signatureManual wrapper

Real-World Examples

1. File Read

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

2. File Write

fs.writeFile('file.txt', 'content', (err) => {
  if (err) throw err;
  console.log('written');
});

3. DNS Lookup

dns.lookup('example.com', (err, address) => {
  if (err) throw err;
  console.log(address);
});

4. Custom Error-First Function

function validate(input, callback) {
  if (!input) {
    callback(new Error('empty input'));
    return;
  }
  callback(null, input.trim());
}

5. Promisify fs.readFile

const readFile = promisify(require('fs').readFile);
const data = await readFile('file.txt', 'utf8');

6. Manual Promise Wrapper

function asyncFn(arg) {
  return new Promise((resolve, reject) => {
    callbackFn(arg, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

7. Consistent Async Callback

function getValue(callback) {
  process.nextTick(() => callback(null, 42));
}

8. Stream Events

stream.on('data', (chunk) => process(chunk));
stream.on('error', (err) => console.error(err));

9. Server Handler

http.createServer((req, res) => {
  res.end('hello');
}).listen(8080);

10. Express Middleware

app.use((req, res, next) => {
  console.log(req.method, req.url);
  next();
});

Visual

Error-First Convention

┌──────────────────────────────────────────────────────────────┐
│  callback(err, result)                                       │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  if (err) {                                            │  │
│  │    // handle error                                     │  │
│  │    return;                                             │  │
│  │  }                                                     │  │
│  │  // use result                                         │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  err === null  →  success, result is valid                   │
│  err !== null  →  failure, result is undefined               │
└──────────────────────────────────────────────────────────────┘

Callback Nesting

┌──────────────────────────────────────────────────────────────┐
│  readFile(a, (err, a) => {                                   │
│    if (err) return;                                          │
│    readFile(b, (err, b) => {                                 │
│      if (err) return;                                        │
│      readFile(c, (err, c) => {                               │
│        if (err) return;                                      │
│        // ...                                                │
│      });                                                     │
│    });                                                       │
│  });                                                         │
│                                                              │
│  Each level adds indentation and a repeated error check.     │
└──────────────────────────────────────────────────────────────┘

Promisify

┌──────────────────────────────────────────────────────────────┐
│  Callback API:                                               │
│  fs.readFile(path, options, callback)                        │
│                                                              │
│  Promisified:                                                │
│  const readFile = promisify(fs.readFile)                     │
│  await readFile(path, options)                               │
│                                                              │
│  The callback is removed; the error becomes a rejection.     │
└──────────────────────────────────────────────────────────────┘

Synchronous vs Asynchronous Callback

┌──────────────────────────────────────────────────────────────┐
│  SYNCHRONOUS:                                                │
│  [1, 2, 3].forEach((n) => console.log(n));                   │
│  console.log('after');                                       │
│  Output: 1, 2, 3, after                                      │
│                                                              │
│  ASYNCHRONOUS:                                               │
│  setTimeout(() => console.log('later'), 0);                  │
│  console.log('now');                                         │
│  Output: now, later                                          │
│                                                              │
│  CONSISTENT ASYNCHRONOUS:                                    │
│  process.nextTick(() => callback(null, cached));             │
│  └── Runs after the current operation, not synchronously     │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
CallbackFunction invoked when an operation completes
Error-first(err, result) signature
Error nullSuccess
Error setFailure
Return after errorDo not use the result
Synchronous callbackRuns immediately
Asynchronous callbackRuns after the current operation
Consistent asyncAlways asynchronous, even for cached results
util.promisifyConverts error-first APIs to promises
Manual wrappernew Promise for non-standard signatures
NestingSequential callbacks indent deeply
Callbacks still usedStreams, events, server handlers

Key takeaways:

  • A callback is a function invoked when an asynchronous operation completes. It is the original mechanism for delivering asynchronous results in Node.js.
  • The error-first convention places the error as the first argument. If the error is null or undefined, the operation succeeded and the result is valid. If the error is set, the operation failed and the result is undefined.
  • The callback always checks the error first and returns if it is set. Using the result after an error is a bug, because the result may be undefined or stale.
  • Callbacks should be called asynchronously, even when the result is available immediately. A callback that is sometimes synchronous and sometimes asynchronous is unpredictable. process.nextTick defers a synchronous result to the end of the current operation.
  • Nested callbacks produce deep indentation and repeated error checks. This is the “callback hell” that motivated promises. Named functions flatten the structure but scatter the logic.
  • util.promisify converts error-first APIs into promise-returning ones. The callback is removed, and the error becomes a rejection. The manual new Promise wrapper is the fallback for non-standard signatures.
  • Callbacks are still used in streams, events, and server handlers. The EventEmitter pattern and the http.createServer handler are callback-based, and understanding the convention is necessary even in promise-based code.

Remember: Callbacks are the foundation on which promises were built. The error-first convention is the standard that the entire Node.js ecosystem follows, and understanding it is necessary for reading legacy code, wrapping older libraries, and using the parts of the core API that remain callback-based. The problems that callbacks create — nesting, repeated error handling, difficult composition — are exactly the problems that promises solve. When writing new code, use promises and async/await. When reading old code or integrating with libraries that use callbacks, understand the convention and use util.promisify to bridge the two styles.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!