Every Node.js developer encounters errors--they're inevitable in production applications. But how you handle those errors determines whether your application recovers gracefully or crashes catastrophically. Modern web applications built with Next.js and similar frameworks demand robust error handling that protects user experience while providing actionable diagnostics for developers.
This guide explores Node.js built-in error classes, demonstrates how to create custom error hierarchies, and reveals patterns that keep applications running smoothly under pressure.
Why Error Handling Matters in Modern Applications
Error handling is the difference between a minor inconvenience and a complete system failure. In production environments, unhandled errors can cascade into service disruptions, data corruption, and frustrated users. Beyond user experience, proper error handling provides the diagnostic breadcrumbs that developers need to identify and fix issues quickly.
Modern web applications face unique challenges. APIs must respond consistently even when downstream services fail. Background jobs need retry logic and dead-letter handling. Real-time features require graceful degradation when connections drop. Each of these scenarios demands thoughtful error handling strategies.
The performance angle matters too. Poorly implemented error handling creates unnecessary overhead. Throwing and catching errors in hot paths can impact response times. Understanding when errors are appropriate versus when fallback logic is better ensures your application remains responsive under load.
According to Sematext's Node.js error handling guide, operational errors--expected failures like network timeouts and database connection issues--require different handling than programming errors that indicate bugs in your code.
Node.js Built-in Error Classes
Node.js provides a hierarchy of built-in error classes, each designed for specific error scenarios. Understanding these classes helps you choose the right error type for each situation. As documented in the Node.js official documentation, these built-in errors form the foundation for all error handling in JavaScript applications. Familiarity with these error types--along with understanding JavaScript's unique quirks and behaviors--is essential for writing robust server-side code.
| Error Class | Use When | Example |
|---|---|---|
| Error | Base class for all errors | Generic failure |
| TypeError | Operation on wrong type | Calling non-function |
| ReferenceError | Accessing undefined variable | Using undefined variable |
| SyntaxError | Invalid JavaScript syntax | eval('x =') |
| RangeError | Value outside valid range | Array length -1 |
| SystemError | OS-level operation failure | File not found |
| AssertionError | Failed assertion | assert.strictEqual(2, 5) |
1// Error - the foundation class2const error = new Error('Something went wrong');3console.log(error.message); // 'Something went wrong'4console.log(error.stack); // Stack trace5 6// TypeError - wrong type operation7function divide(a, b) {8 if (typeof a !== 'number' || typeof b !== 'number') {9 throw new TypeError('Both arguments must be numbers');10 }11 if (b === 0) {12 throw new RangeError('Cannot divide by zero');13 }14 return a / b;15}16 17// SystemError - OS-level failures18const fs = require('fs');19try {20 fs.readFileSync('/nonexistent/file.txt');21} catch (error) {22 console.log(error.code); // 'ENOENT'23 console.log(error.syscall); // 'open'24 console.log(error.path); // '/nonexistent/file.txt'25}Creating Custom Error Classes
While built-in error classes handle common scenarios, production applications benefit from custom error hierarchies that encode business logic, HTTP responses, and application-specific context. As demonstrated by NamasteDev's error handling patterns, custom error classes with status codes and error codes enable programmatic handling and consistent API responses.
When building type-safe applications, consider combining custom errors with TypeScript generics and type systems to catch type-related errors at compile time rather than runtime.
1class AppError extends Error {2 constructor(message, statusCode, errorCode = 'INTERNAL_ERROR') {3 super(message);4 this.name = 'AppError';5 this.statusCode = statusCode;6 this.errorCode = errorCode;7 this.isOperational = true;8 Error.captureStackTrace(this, this.constructor);9 }10}11 12// Error code constants13const ErrorCodes = {14 VALIDATION_ERROR: 'VALIDATION_ERROR',15 AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',16 AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',17 NOT_FOUND: 'NOT_FOUND',18 RATE_LIMITED: 'RATE_LIMITED',19 INTERNAL_ERROR: 'INTERNAL_ERROR',20 SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE'21};22 23// Usage24throw new AppError('User not found', 404, 'NOT_FOUND');25throw new AppError('Database connection failed', 503, 'SERVICE_UNAVAILABLE');26throw new AppError('Invalid input data', 400, 'VALIDATION_ERROR');1class RepositoryError extends Error {2 constructor(message, { cause, operation, entity }) {3 super(message, { cause });4 this.name = 'RepositoryError';5 this.operation = operation;6 this.entity = entity;7 this.timestamp = new Date();8 }9}10 11async function getUser(userId) {12 try {13 return await database.users.findById(userId);14 } catch (error) {15 throw new RepositoryError('Failed to fetch user', {16 cause: error,17 operation: 'findById',18 entity: 'User'19 });20 }21}22 23// Later, when logging:24try {25 await getUser('invalid-id');26} catch (error) {27 if (error.cause) {28 console.log('Original error:', error.cause.message);29 }30}Error Handling Patterns for Different Scenarios
Try-Catch for Synchronous Code
The try-catch block handles synchronous errors:
function parseUserInput(jsonString) {
try {
const data = JSON.parse(jsonString);
if (!data.email) {
throw new AppError('Email is required', 400, 'VALIDATION_ERROR');
}
return data;
} catch (error) {
if (error instanceof SyntaxError) {
throw new AppError('Invalid JSON format', 400, 'JSON_PARSE_ERROR');
}
throw error;
}
}
Async/Await Error Handling
Async/await makes asynchronous error handling more intuitive:
async function fetchUserProfile(userId) {
try {
const user = await getUser(userId);
const posts = await getUserPosts(userId);
return { user, posts };
} catch (error) {
if (error.errorCode === 'NOT_FOUND') {
logger.warn(`User not found: ${userId}`);
return null;
}
logger.error(`Failed to fetch profile for ${userId}`, { error });
throw error;
}
}
Promise Error Handling
Promise chains require .catch() for error handling:
function fetchDataWithRetry(url, retries = 3) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new HttpError(`HTTP ${response.status}`, response.status);
}
return response.json();
})
.catch(error => {
if (retries > 0 && error.isRetryable) {
return fetchDataWithRetry(url, retries - 1);
}
throw error;
});
}
// Promise.allSettled - handles all results
async function fetchAllDataSafely(items) {
const results = await Promise.allSettled(
items.map(item => fetchData(item))
);
const errors = results.filter(r => r.status === 'rejected');
const successes = results.filter(r => r.status === 'fulfilled');
return { successes, errors };
}
Centralized Error Handling Middleware
Express applications benefit from centralized error handling that ensures consistent responses and proper logging. Following patterns from NamasteDev's Express error handling guide, middleware patterns allow you to handle errors in one place while maintaining detailed logging for debugging.
1// Custom error classes2class ValidationError extends AppError {3 constructor(message, fields = {}) {4 super(message, 400, 'VALIDATION_ERROR');5 this.name = 'ValidationError';6 this.fields = fields;7 }8}9 10class NotFoundError extends AppError {11 constructor(resource, id) {12 super(`${resource} not found`, 404, 'NOT_FOUND');13 this.name = 'NotFoundError';14 this.resource = resource;15 this.id = id;16 }17}18 19// Centralized error handling middleware20function errorHandler(err, req, res, next) {21 logger.error('Request error', {22 error: err.message,23 stack: err.stack,24 statusCode: err.statusCode,25 errorCode: err.errorCode,26 path: req.path,27 method: req.method28 });29 30 if (err instanceof ValidationError) {31 return res.status(400).json({32 success: false,33 error: { code: err.errorCode, message: err.message, fields: err.fields }34 });35 }36 37 if (err instanceof NotFoundError) {38 return res.status(404).json({39 success: false,40 error: { code: err.errorCode, message: err.message }41 });42 }43 44 const statusCode = err.statusCode || 500;45 const message = process.env.NODE_ENV === 'production'46 ? 'Internal server error'47 : err.message;48 49 res.status(statusCode).json({50 success: false,51 error: { code: 'INTERNAL_ERROR', message }52 });53}54 55// Async wrapper for route handlers56function asyncHandler(fn) {57 return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);58}Performance Considerations
Error handling has performance implications that matter in production applications. According to Sematext's performance analysis, creating Error objects involves capturing stack traces, which has measurable overhead--especially in hot code paths.
Error Creation Overhead
Creating Error objects involves capturing stack traces, which has measurable overhead:
// Avoid in hot paths - stack trace capture is expensive
function validateInput(data) {
for (const key in data) {
if (!data[key]) {
throw new Error(`${key} is required`);
}
}
}
// Better - validate first, throw once
function validateInput(data) {
const missing = Object.keys(data).filter(key => !data[key]);
if (missing.length > 0) {
throw new ValidationError(`Missing: ${missing.join(', ')}`);
}
}
// Even better - return boolean for validation
function validateInput(data) {
const missing = Object.keys(data).filter(key => !data[key]);
return { valid: missing.length === 0, missing };
}
Caching Error Instances
For frequently thrown errors with the same message, consider caching:
class ErrorCache {
constructor() { this.cache = new Map(); }
get(type, message) {
const key = `${type}:${message}`;
if (!this.cache.has(key)) {
const ErrorClass = this.errorClasses[type] || AppError;
this.cache.set(key, new ErrorClass(message));
}
return this.cache.get(key);
}
}
const errorCache = new ErrorCache();
throw errorCache.get('VALIDATION', 'Email is required');
Understanding JavaScript's unique behavior patterns helps you write more efficient error handling code that avoids common pitfalls.
Choose the Right Error Class
Use built-in error classes when they fit--TypeError for type errors, RangeError for bounds, SystemError for OS-level failures.
Distinguish Error Types
Operational errors are expected and should be handled gracefully. Programming errors indicate bugs and should crash the process.
Create Error Hierarchies
Custom error classes with status codes and error codes enable programmatic handling and consistent responses.
Use Error Cause
The cause property in modern Node.js preserves error context through multiple layers of your application.
Centralize Error Handling
Express middleware or equivalent patterns ensure consistent error responses and logging across your application.
Log Meaningfully
Include request context, user information, and correlation IDs in error logs for easy debugging.
Consider Performance
Avoid creating errors in hot paths. Use validation-before-throw patterns and consider error caching.
Monitor and Alert
Track error rates by type. Alert on unexpected increases. Use error codes to categorize and prioritize fixes.
Frequently Asked Questions
10 Oddities and Secrets About JavaScript
Explore lesser-known JavaScript features and quirks that can impact your error handling strategies.
Learn moreUnderstanding TypeScript Generics
Learn how TypeScript generics can help prevent type-related errors at compile time.
Learn moreUseful Frontend Boilerplates and Starter Kits
Jumpstart your projects with battle-tested boilerplates that include proper error handling.
Learn more