Every Node.js application encounters errors. The difference between a resilient application and one that crashes under pressure often comes down to how those errors are handled. This guide explores modern error handling patterns that keep your Node.js applications stable, maintainable, and production-ready.
Understanding Error Types in Node.js
Node.js errors fall into two fundamental categories that require different handling approaches. Operational errors represent expected failures that any robust application should handle gracefully--network timeouts, database connection failures, invalid user input, and file system permission issues all fall into this category. Programmer errors, on the other hand, signal actual bugs in your application code.
1// Operational Errors - Expected failures2const operationalErrors = [3 'ECONNREFUSED', // Database connection failed4 'ETIMEDOUT', // Network request timed out5 'ENOENT', // File not found6 'EACCES', // Permission denied7];8 9// Programmer Errors - Actual bugs10function buggyCode() {11 // Reading property of undefined12 const data = null;13 console.log(data.property); // TypeError14}15 16// Calling non-existent function17const result = nonExistent(); // ReferenceErrorUnderstanding this distinction is crucial because it determines your response strategy. When operational errors occur, your application should degrade gracefully--retry failed operations, serve cached data, or display helpful error messages. Programmer errors, conversely, indicate bugs that need fixing at the source rather than sophisticated runtime handling.
In production environments, unhandled errors can cascade into system failures, data corruption, and poor user experiences. Modern error handling goes beyond simply catching exceptions. It involves creating comprehensive strategies that include error logging, monitoring, alerting, and graceful degradation.
Synchronous Error Handling with Try-Catch
The try-catch statement forms the foundation of error handling in synchronous JavaScript code. When you wrap potentially failing code in a try block, any exceptions thrown within that block are caught and can be handled in the corresponding catch block.
1try {2 // Code that might throw an error3 const data = JSON.parse(userInput);4 validateData(data);5 processData(data);6} catch (error) {7 // Handle the error appropriately8 console.error('Failed to process user input:', error.message);9 return { success: false, error: error.message };10}Error Object Properties
The Error object provides several valuable properties for debugging:
- message: Human-readable description of the error
- stack: Stack trace showing where the error occurred
- name: Type of error (TypeError, ReferenceError, etc.)
Nested Try-Catch and Re-throwing
When working with nested operations, you can use nested try-catch blocks to handle different error types at different levels. Sometimes you'll catch an error, log it, and re-throw it for upstream handling--this pattern is essential for maintaining proper error boundaries while preserving debugging context.
function processUserData(input) {
try {
const parsed = parseInput(input);
return validateAndTransform(parsed);
} catch (error) {
if (error instanceof ValidationError) {
// Handle validation errors locally
return { valid: false, issues: error.issues };
}
// Re-throw unexpected errors for upstream handling
throw new ProcessingError('Failed to process user data', { cause: error });
}
}
Custom Error Subclasses
Extending the Error class allows you to create specialized error types that carry additional context. This pattern enables precise error handling downstream--you can catch specific error types and respond accordingly. For TypeScript projects, consider combining this with type validation techniques to catch type-related errors early.
Asynchronous Error Handling with Async/Await
The async/await syntax has revolutionized error handling by making asynchronous code look and behave more like synchronous code. When working with async functions, you can use try-catch blocks just like with synchronous code, making error handling more intuitive.
1async function fetchUserData(userId) {2 try {3 const response = await fetch(`/api/users/${userId}`);4 5 if (!response.ok) {6 throw new ApiError('Failed to fetch user', response.status);7 }8 9 const userData = await response.json();10 return userData;11 } catch (error) {12 if (error instanceof ApiError) {13 throw error; // Re-throw API errors for upstream handling14 }15 logger.error('User fetch failed:', error);16 throw new DataError('Unable to retrieve user data');17 }18}Error Propagation in Async Functions
A critical pattern in async error handling is error propagation--catching errors at the appropriate level and either handling them locally or re-throwing for upstream handling. This keeps your error handling organized and ensures errors reach handlers with the context needed to respond appropriately.
Common Pitfalls
The most frequent error with async/await is forgetting the await keyword. When you call an async function without await, it returns a promise immediately, and any errors from that function become unhandled promise rejections. Always ensure async calls are properly awaited, and consider using linting rules to catch these issues early.
// Common mistake - missing await!
async function badExample() {
doAsyncWork(); // Returns promise, errors won't be caught!
}
// Correct - await the async call
async function goodExample() {
await doAsyncWork(); // Errors will be caught by try-catch
}
Promise Rejection Handling
When promises reject without being caught, Node.js generates an unhandled promise rejection. Modern Node.js versions emit warnings and can crash your application. Always handle rejections explicitly through try-catch or .catch() handlers. For comprehensive coverage of HTTP request patterns, see our guide on fetch API in JavaScript which covers proper error handling for network requests.
Custom Error Classes for Better Error Handling
Creating custom error classes improves your application's error handling by making errors more specific and descriptive. Rather than throwing generic Error objects, you can create specialized error types that carry meaningful context.
1class ValidationError extends Error {2 constructor(message, field, value) {3 super(message);4 this.name = 'ValidationError';5 this.field = field;6 this.value = value;7 this.timestamp = new Date().toISOString();8 }9}10 11class DatabaseError extends Error {12 constructor(message, query, parameters) {13 super(message);14 this.name = 'DatabaseError';15 this.query = query;16 this.parameters = parameters;17 this.retryable = true;18 }19}1function validateUserInput(input) {2 if (!input.email) {3 throw new ValidationError('Email is required', 'email', input.email);4 }5 if (!input.password || input.password.length < 8) {6 throw new ValidationError('Password must be at least 8 characters', 'password', '[REDACTED]');7 }8}Error Codes and Machine-Readable Errors
In production applications, errors often need to be processed programmatically by client applications, monitoring systems, or automated tools. Including error codes alongside human-readable messages enables machines to respond appropriately to different error conditions. Error codes should be stable identifiers that don't change even if error messages are modified.
Error Hierories
Building an error hierarchy helps categorize errors and handle them appropriately. Create base error classes for your application domains (ValidationError, DatabaseError, ApiError) that extend the base Error class, then create specific error types for granular handling. When working with TypeScript, comparing TypeScript with JSDoc can help you decide the best approach for type safety in your error handling strategy.
Error Middleware in Express Applications
Express middleware provides an elegant mechanism for handling errors across your entire application. By defining error-handling middleware functions, you can centralize error processing and ensure consistent error responses.
1function errorHandler(err, req, res, next) {2 // Log the error for debugging3 logger.error({4 message: err.message,5 stack: err.stack,6 path: req.path,7 method: req.method,8 timestamp: new Date().toISOString()9 });10 11 const statusCode = err.statusCode || err.status || 500;12 13 res.status(statusCode).json({14 error: {15 code: err.code || 'INTERNAL_ERROR',16 message: statusCode === 500 ? 'An unexpected error occurred' : err.message,17 ...(process.env.NODE_ENV === 'development' && { stack: err.stack })18 }19 });20}1function asyncHandler(fn) {2 return (req, res, next) => {3 Promise.resolve(fn(req, res, next)).catch(next);4 };5}6 7// Usage in routes8app.get('/users/:id', asyncHandler(async (req, res) => {9 const user = await getUserById(req.params.id);10 if (!user) {11 throw new NotFoundError('User not found');12 }13 res.json(user);14}));Centralized Error Response Format
Consistent error responses help client applications handle errors gracefully and provide better user experiences. A well-designed error response includes a machine-readable code, a user-friendly message, optional details for complex errors, and metadata like timestamps and request IDs for correlation.
Error Monitoring Integration
Connect your Express error middleware to error tracking services like Sentry, Datadog, or New Relic to aggregate errors across your production environment, track error frequency, and receive alerts on concerning patterns.
Global Error Handlers and Process-Level Events
Even with comprehensive error handling, some errors will escape your application code. Node.js provides global handlers for uncaught exceptions and unhandled promise rejections that allow you to log errors, perform cleanup, and gracefully shut down.
1// Handle uncaught exceptions2process.on('uncaughtException', (error) => {3 logger.error('Uncaught Exception:', {4 message: error.message,5 stack: error.stack,6 timestamp: new Date().toISOString()7 });8 cleanup();9 process.exit(1);10});11 12// Handle unhandled promise rejections13process.on('unhandledRejection', (reason, promise) => {14 logger.error('Unhandled Rejection:', {15 reason: reason?.message || reason,16 stack: reason?.stack,17 promise: promise.toString(),18 timestamp: new Date().toISOString()19 });20});21 22// Graceful shutdown handler23process.on('SIGTERM', () => {24 logger.info('SIGTERM received, shutting down gracefully');25 server.close(() => {26 logger.info('Server closed');27 process.exit(0);28 });29});Graceful Shutdown
When uncaught errors occur, a graceful shutdown prevents data corruption and ensures in-flight requests complete or fail cleanly. Your shutdown handler should stop accepting new connections, allow existing requests to complete, close database connections, and flush logs before exiting.
Using process managers like PM2 or container orchestration systems can automatically restart your application after crashes, but graceful shutdown logic ensures restarts don't leave your system in an inconsistent state.
Error Logging and Monitoring Integration
Effective error handling requires comprehensive logging that captures enough context for debugging. Modern applications integrate with error tracking services that aggregate errors, provide dashboards, and alert on concerning patterns.
1function createRequestLogger(req) {2 const requestId = uuidv4();3 4 return {5 info: (message, data = {}) => {6 logger.info({ requestId, ...data, path: req.path, method: req.method });7 },8 error: (message, error) => {9 logger.error({10 requestId,11 message: error.message,12 stack: error.stack,13 path: req.path,14 method: req.method,15 body: sanitizeBody(req.body),16 query: req.query17 });18 19 errorMonitoring.captureException(error, {20 extra: { requestId, path: req.path }21 });22 }23 };24}Choosing an Error Monitoring Service
Popular error monitoring services include Sentry, Datadog, New Relic, and CloudWatch. These services aggregate errors from multiple instances, provide stack trace visualization, track error frequency over time, and integrate with alerting systems.
When selecting a monitoring service, consider its integration with your existing stack, sampling capabilities for high-volume applications, and alert customization options. The goal is visibility into errors occurring across your production environment. For applications that also leverage AI-powered automation, integrating error monitoring with AI automation services can provide intelligent alerting and automated response workflows.
Best Practices for Production Error Handling
Robust error handling in production requires consistent practices:
- Differentiate error types: Handle operational errors gracefully; fix programmer errors at the source
- Keep error handling local: Use explicit try-catch rather than relying on global handlers
- Create custom error types: Carry meaningful context for debugging
- Maintain consistent error responses: Never expose sensitive information to users
- Implement comprehensive logging: Capture error context while respecting privacy
- Monitor and alert: Set up monitoring services to track error patterns
Testing Error Handling
Your error handling code deserves testing just like any other application logic. Write tests that verify your error classes carry the right properties, that middleware transforms errors into correct responses, and that custom error types are caught by appropriate handlers.
Integration tests should verify that errors propagate correctly through your middleware chain and that unhandled errors trigger your global handlers. These tests catch regressions that might otherwise only be discovered in production.
Documenting Your Error Strategy
Document your error handling strategy so team members can maintain consistent practices. Define your error hierarchy, establish conventions for error codes and messages, and document how errors should flow through your application. This documentation ensures your entire team follows the same patterns.
Local Error Handling
Catch and handle errors at the appropriate level rather than relying on global catch-all handlers
Custom Error Types
Create specialized error classes that carry meaningful context for debugging
Consistent Responses
Ensure all error responses follow a standardized format with appropriate information
Monitoring Integration
Connect error handling to monitoring services for production visibility
Frequently Asked Questions
Sources
- Sematext - Node.js Error Handling Best Practices - Comprehensive guide covering operational vs programmer errors, async/await patterns, and custom error classes
- VivaSoft - Mastering Node.js: Error Handling Practices - Detailed coverage of error middleware, uncaught exception handling, and best practices for production applications