Skip to main content

What is error-first callback pattern?

Error-first callback pattern - a Node.js convention where async functions always pass an error as the first argument to their callback, followed by result data if no error occurred.

Theory

TL;DR

  • Think of it like a delivery driver: first they report if there was a problem (error), then hand over the package (data) only if everything went fine
  • Convention: callback(err, data) - error first, data second
  • On success: err is null, data holds the result; on failure: err is an Error object and data is undefined
  • Always check if (err) before touching data, and always return after - skipping the return is the #1 bug
  • Use for fs, mysql, pg and similar libs; prefer Promises for new code

Quick example

javascript
const fs = require('fs'); fs.readFile('config.json', 'utf8', (err, content) => { if (err) { console.error('Failed to read:', err.message); // ENOENT: no such file... return; // Critical - stops execution here } console.log(content); // { port: 3000 } - only runs if no error });

Error arrives first. If the file is missing, err is set and content is undefined. The return keeps the rest of the callback from running with bad data.

Why error comes first

In synchronous code, you throw an exception and a try-catch somewhere up the stack catches it. That doesn't work for async code. The callback runs later, in a different turn of the event loop, so the original try-catch is long gone. Putting the error first forces you to handle it manually, every time. There is no way to accidentally skip it.

When to use

  • Reading or writing files with fs: use error-first callbacks
  • Database queries with mysql or pg: both follow this convention
  • Writing a custom async function: pass (err, result) to match Node stdlib
  • Wrapping a legacy third-party lib: error-first keeps things consistent
  • New code on Node 10+: prefer fs.promises and async/await instead

How Node.js handles it internally

libuv threads manage I/O operations like fs.readFile. When the op finishes, libuv passes a C-level error code (for example, UV_ENOENT) or zero back to V8. The event loop then calls your callback with either an Error built from that code, or null. The pattern itself is a convention - nothing in the runtime enforces it, which is exactly why forgetting return is such a common bug.

Common mistakes

Forgetting return after handling the error

javascript
// Wrong - falls through to data block fs.readFile('bad.txt', (err, data) => { if (err) console.error(err); // No return! console.log(data); // Runs anyway - logs undefined }); // Right fs.readFile('bad.txt', (err, data) => { if (err) { console.error(err); return; } console.log(data); });

Without return, execution continues with data === undefined. I've seen this cause silent writes of undefined to production databases - the code appears to work but corrupts records.

Swapped argument order in a custom function

javascript
// Wrong - breaks every caller function getUser(id, cb) { db.query(sql, [id], (err, row) => cb(row, err)); // swapped! } // Right function getUser(id, cb) { db.query(sql, [id], (err, row) => cb(err, row)); }

Every Node.js lib expects (err, data). Swap the arguments and callers end up checking the wrong one.

Wrapping async code in try-catch

javascript
// Does nothing useful try { fs.readFile('file.txt', (err, data) => { throw new Error('boom'); // Not caught by the outer try-catch }); } catch (e) { console.error(e); // Never runs }

The try-catch exits before the callback fires. Handle errors inside the callback.

Real-world usage

  • fs.readFile, fs.writeFile: Node.js core, this pattern since v0.1.90
  • mysqljs/mysql: connection.query(sql, params, (err, rows) => {}) - over 10M weekly downloads
  • pg (Postgres): client.query(sql, (err, result) => {}) - standard in most Node backends
  • Express: the (err, req, res, next) error middleware signature extends this convention
  • Converting to Promise: util.promisify(fs.readFile)('file.txt').then(...).catch(...)

Follow-up questions

Q: Why doesn't Node.js just throw exceptions for async errors?
A: Async callbacks run after the original call stack is gone. A thrown exception inside a callback has nowhere to be caught and crashes the process. Error-first keeps control inside the callback.

Q: How do you convert an error-first callback to a Promise?
A: Use util.promisify: const readFile = util.promisify(fs.readFile). Then call it with .then().catch() or inside async/await.

Q: What if you need to return multiple results?
A: Pass an object or array as the second argument: cb(null, { user, token }). One error, one result - pack multiple values together.

Q: In a chain of nested callbacks, how do you avoid writing if (err) return cb(err) in every step?
A: Extract each step into a named function, or use the async library's waterfall. For new code, async/await solves this with a single try-catch over the whole chain.

Examples

Basic: file read with error propagation

javascript
const fs = require('fs'); function readConfig(path, cb) { fs.readFile(path, 'utf8', (err, content) => { if (err) return cb(err); // Propagate error up cb(null, JSON.parse(content)); // Pass parsed data on success }); } readConfig('config.json', (err, config) => { if (err) { console.error('Config load failed:', err.message); // ENOENT... return; } console.log('Port:', config.port); // Port: 3000 });

cb(err) propagates errors without hiding them. cb(null, result) signals success. The caller handles both paths.

Intermediate: Postgres query in Express

javascript
const express = require('express'); const pg = require('pg'); const app = express(); app.get('/user/:id', (req, res) => { const client = new pg.Client(); client.connect((err) => { if (err) return res.status(500).json({ error: err.message }); client.query( 'SELECT * FROM users WHERE id = $1', [req.params.id], (err, result) => { client.end(); if (err) return res.status(500).json({ error: err.message }); res.json(result.rows[0]); // { id: 1, name: 'Alice' } } ); }); });

Each async step checks err before going further. return on each error path prevents sending two responses. This nesting pattern is what eventually pushed the community toward Promises.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?