Errors in Node.js: Operational vs. Programmer Errors
Before diving into error class patterns, it's important to distinguish between the two primary categories of errors you'll encounter in Node.js development. The first type is operational errors — runtime issues that aren't bugs but rather the result of external factors. These include:
- Failure to connect to a database server;
- Invalid user input (server responds with a
400response code); - Request timeout;
- Resource not found (server responds with a 404 response code);
- Server returning a
500response.
The second category is programmer errors — actual bugs in the code that can only be fixed by changing the source. These cannot be "handled" at runtime because the code itself is broken. Examples include attempting to read a property on an undefined object, invoking an asynchronous function without a callback, or passing a string where a number was expected. This article focuses specifically on operational error handling.
Error handling in Node.js differs significantly from other languages due to JavaScript's asynchronous nature and its unusual openness regarding what can be thrown. In JavaScript, you're not limited to throwing instances of the error class — you can literally throw any data type, a flexibility most other languages don't permit.
For instance, a developer might throw a number instead of an error object instance. While this may not seem problematic at first, doing so makes debugging considerably harder. You lose the stack trace and other properties that the Error object exposes, which are essential for tracing issues back to their source.
Anti-Pattern #1: Callback Misuse
Consider a scenario where your code depends on an external API that requires a callback. Prior to Node.js 8, developers could "fire and forget" commands — no callback was required, and consequently, error handling was often omitted entirely. If the target folder for a writeFile operation doesn't exist, the call silently fails with no indication. This also introduces race conditions, since you have no way of knowing whether the first command finished before the second one starts.
The immediate fix is to add a callback to the first command (mkdir) to ensure the directory exists before attempting to write to it. This solves the race condition but leaves another problem: you still have no way to know whether the folder was actually created. If it wasn't, the second call fails again, silently.
Proper Callback Error Handling
Correct callback-based error handling requires the error-first approach. You must always check whether an error was returned before using any data from the call. A common anti-pattern looks like this:
'use strict';
// Wrong
const fs = require('fs');
const write = function (callback) {
fs.mkdir('./writeFolder', (err, data) => {
if (data) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!');
else callback(err)
});
}
write(console.log);
This approach is flawed for two reasons. First, some APIs may return no value or a falsy value as a legitimate response, which would incorrectly trigger your error case. Second, this pattern can silently swallow errors — if the API doesn't return a value in an error scenario, your error handler never fires. The correct pattern checks for the error parameter directly:
'use strict';
// Right
const fs = require('fs');
const write = function (callback) {
fs.mkdir('./writeFolder', (err, data) => {
if (err) return callback(err)
fs.writeFile('./writeFolder/foobar.txt', 'Hello World!');
});
}
write(console.log);
Anti-Pattern #2: Promise Hell
Modern Node.js codebases have largely moved away from callbacks to promises. However, promises introduce their own error-handling pitfalls. Consider reimplementing the earlier example with promises:
'use strict';
const fs = require('fs').promises;
const write = function () {
return fs.mkdir('./writeFolder').then(() => {
fs.writeFile('./writeFolder/foobar.txt', 'Hello world!')
}).catch((err) => {
// catch all potential errors
console.error(err)
})
}
Here we're branching from the fs.mkdir promise into a separate chain for fs.writeFile without proper handling of that inner promise. A naive "fix" might involve nesting:
'use strict';
const fs = require('fs').promises;
const write = function () {
return fs.mkdir('./writeFolder').then(() => {
fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => {
// do something
}).catch((err) => {
console.error(err);
})
}).catch((err) => {
// catch all potential errors
console.error(err)
})
}
But this approach doesn't scale. Adding more promise chains leads to ever-increasing indentation — the same "callback hell" that promises were designed to eliminate. You simply end up with promise hell instead.
Promisifying Callback APIs
At times, you'll want to promisify a callback-based API yourself for better error control. This is harder than it appears. If a function only invokes its callback in certain conditions, the promise you create may never settle, leaving it hanging forever — a memory leak in your application.
Swallowed Synchronous Errors
The Promise constructor has inherent limitations. Once a promise is resolved or rejected, it cannot change state. This creates "dead zones" in your code: if a promise resolves successfully, any subsequent synchronous error handling in the executor function is unreachable and will simply be ignored.
function deadZonePromise(arg) {
return new Promise((resolve, reject) => {
doATask(foo, (err) => {
resolve('I’m all Done');
throw new Error('I am never reached') // Dead Zone
});
});
}
From this example, you can see that immediately after the promise resolves, the following line is dead code. Any synchronous error checks placed after the resolution logic will never execute, and their errors will be swallowed silently.
Common Error Handling Mistakes in Production Code
The patterns below appear frequently in real Node.js codebases. Each demonstrates a subtle way that error information gets lost or distorted, making debugging significantly harder than it needs to be.
Mistaking String Concatenation for Error Enrichment
When an API error isn’t descriptive enough, developers often try to add context by concatenating a message onto the original error object:
'use strict';
function readTemplate() {
return new Promise(() => {
databaseGet('query', function(err, data) {
if (err) {
reject('Template not found. Error: ', + err);
} else {
resolve(data);
}
});
});
}
readTemplate();
This approach implicitly calls toString() on the error, discarding all structured information that came with it — most critically, the stack trace. What you're left with is a plain string that offers no debugging value. Instead, you should either keep the original error untouched or wrap it in your own error type, attaching the original error as a property for later inspection.
Swallowing Errors Entirely
A common pattern in user signup flows is to catch a database error and respond to the user with a generic message while ignoring the actual error object:
router.get('/:id', function (req, res, next) {
database.getData(req.params.userId)
.then(function (data) {
if (data.length) {
res.status(200).json(data);
} else {
res.status(404).end();
}
})
.catch(() => {
log.error('db.rest/get: could not get data: ', req.params.userId);
res.status(500).json({error: 'Internal server error'});
})
});
The problem here is that the error is completely discarded without logging. A database failure might actually be caused by malformed user input — which should be a 400 response, not a 500. By blindly returning a 500, you’ve made the user’s report useless and left yourself with hours of guesswork re-creating the scenario.
Over-Transforming Errors from External APIs
A more involved example shows how developers sometimes contort errors they receive from external calls, making them far less useful in the process:
async function doThings(input) {
try {
validate(input);
try {
await db.create(input);
} catch (error) {
error.message = `Inner error: ${error.message}`
if (error instanceof Klass) {
error.isKlass = true;
}
throw error
}
} catch (error) {
error.message = `Could not do things: ${error.message}`;
await rollback(input);
throw error;
}
}
There are several issues with this code structure:
- Nested
try/catchblocks: Each one adds surface area for error handling. The goal should be to reduce the number of places where errors are caught, centralizing the handling logic. - Message manipulation: Attempting to “improve” the error message by altering it often destroys the meaning of the original error.
- Redundant property checking: If an error is already an instance of
Klass, setting anisKlassflag adds nothing — the type check already tells you that. - Premature rollback: The code rolls back the database transaction even when the database was never touched in the failing path.
A more maintainable version looks like this:
async function doThings(input) {
validate(input);
try {
await db.create(input);
} catch (error) {
try {
await rollback();
} catch (error) {
logger.log('Rollback failed', error, 'input:', input);
}
throw error;
}
}
This revision uses a single try/catch for the main logic, with a nested one only as a safety net around the rollback call. Critically, it rethrows the original error so no information is lost along the way.
Unhandled Promise Rejections
Unhandled rejections occur when a promise is rejected but no handler is attached to it. The code picks up and continues running, leaving you with an untracked failure.
Consider this example:
'use strict';
async function foobar() {
throw new Error('foobar');
}
async function baz() {
throw new Error('baz')
}
(async function doThings() {
const a = foobar();
const b = baz();
try {
await a;
await b;
} catch (error) {
// ignore all errors!
}
})();
At first glance it looks solid, but if a rejects, the code never reaches await b. Since nothing is awaiting or catching the rejection of b, it becomes an unhandled rejection. A straightforward fix is to combine both promises into a single Promise.all:
'use strict';
async function foobar() {
throw new Error('foobar');
}
async function baz() {
throw new Error('baz')
}
(async function doThings() {
const a = foobar();
const b = baz();
try {
await Promise.all([a, b]);
} catch (error) {
// ignore all errors!
}
})();
A similar issue can arise with a single promise when you forget to await the value you're trying to guard:
'use strict';
async function foobar() {
throw new Error('foobar');
}
async function doThings() {
try {
return foobar()
} catch {
// ignoring errors again !
}
}
doThings();
Although it’s subtle, returning foobar happens before the try/catch has a chance to handle it. To fix it, you must await the promise within the guarded block:
'use strict';
async function foobar() {
throw new Error('foobar');
}
async function doThings() {
try {
return await foobar()
} catch {
// ignoring errors again !
}
}
doThings();
Adopting an Error Class Hierarchy
Rather than scattering throw new Error(...) throughout your application, a more disciplined approach is to define a small tree of error classes. At the top sits an ApplicationError class, with two primary subclasses:
ApplicationError— the base class from which all other error types inherit.DatabaseError— base class for any error originating from database operations.UserFacingError— base class for errors triggered by user interaction.
These class definitions might look like this:
'use strict';
// Here is the base error classes to extend from
class ApplicationError extends Error {
get name() {
return this.constructor.name;
}
}
class DatabaseError extends ApplicationError { }
class UserFacingError extends ApplicationError { }
module.exports = {
ApplicationError,
DatabaseError,
UserFacingError
}
From there you can create specific types such as a NotFoundError or a BadRequestError, both inheriting from UserFacingError. This gives any developer reading the codebase a clear signal about what went wrong. It also lets you attach properties that are guaranteed to exist on certain error types — for example, a statusCode on every UserFacingError — which simplifies handling them later.
Here’s how that would work with simple subclasses:
const { UserFacingError } = require('./baseErrors')
class BadRequestError extends UserFacingError {
constructor(message, options = {}) {
super(message);
// You can attach relevant information to the error instance
// (e.g.. the username)
for (const [key, value] of Object.entries(options)) {
this[key] = value;
}
}
get statusCode() {
return 400;
}
}
class NotFoundError extends UserFacingError {
constructor(message, options = {}) {
super(message);
// You can attach relevant information to the error instance
// (e.g.. the username)
for (const [key, value] of Object.entries(options)) {
this[key] = value;
}
}
get statusCode() {
return 404
}
}
module.exports = {
BadRequestError,
NotFoundError
}
Putting Error Classes to Work
To get the most out of this pattern, consider these practices:
- Create a module for your error classes so they can be imported consistently wherever needed.
- Throw only the errors you’ve defined. If every explicitly thrown error is an instance of your hierarchy, you have a reliable source of truth for understanding failures.
- Centralize error knowledge. Keeping all error definitions in one place makes auditing what your application can throw considerably easier.
- Handle errors by layer, be it database, HTTP framework, or application logic, rather than scattering
try/catchin every function.
In a typical Express setup, implementing this pattern looks like:
const { DatabaseError } = require('./error')
const { NotFoundError } = require('./userFacingErrors')
const { UserFacingError } = require('./error')
// Express
app.get('/:id', async function (req, res, next) {
let data
try {
data = await database.getData(req.params.userId)
} catch (err) {
return next(err);
}
if (!data.length) {
return next(new NotFoundError('Dataset not found'));
}
res.status(200).json(data)
})
app.use(function (err, req, res, next) {
if (err instanceof UserFacingError) {
res.sendStatus(err.statusCode);
// or
res.status(err.statusCode).send(err.errorCode)
} else {
res.sendStatus(500)
}
// do your logic
logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user)
});
Express exposes a global error handler that gives you a single point for error handling. The next() calls in the route handlers pass errors upstairs to the final middleware. Since Express doesn’t natively support async handlers, wrapping the logic in try/catch is still necessary. In the handler, you only need to check if the error is a UserFacingError; if it is, the statusCode is already present, and you can send it back to the client. Any error that isn’t an instance of your defined classes is treated as an unexpected 500, which prevents 400-level errors from being masked as server failures.



