N ! { } NODE.JS • ERROR HANDLING • BEST PRACTICES
Backend

Node.js Error Handling Best Practices

Mayur Dabhi
Mayur Dabhi
March 29, 2026
24 min read

Error handling is one of the most critical yet often overlooked aspects of Node.js development. Poor error handling leads to crashed applications, security vulnerabilities, and frustrated users. In production environments, how you handle errors can mean the difference between a minor hiccup and a complete system failure.

This comprehensive guide covers everything you need to know about handling errors in Node.js—from understanding error types and using try-catch effectively, to building centralized error handling systems, implementing graceful shutdowns, and logging errors for debugging. By the end, you'll have a production-ready error handling strategy.

What You'll Learn
  • Understanding operational vs programmer errors
  • Creating custom error classes for different scenarios
  • Handling synchronous and asynchronous errors properly
  • Building centralized error handling middleware for Express
  • Implementing graceful shutdown for production
  • Error logging and monitoring strategies
  • Avoiding common error handling anti-patterns

Understanding Error Types in Node.js

Before diving into handling strategies, it's crucial to understand that not all errors are created equal. Node.js errors generally fall into two categories, and each requires a different approach.

Types of Errors in Node.js Operational Errors (Expected, Recoverable) Failed to connect to database Invalid user input Request timeout File not found API rate limit exceeded → Handle gracefully, continue running Programmer Errors (Bugs, Unrecoverable) TypeError: undefined is not a function ReferenceError: x is not defined Reading property of null Missing required argument Stack overflow → Fix the bug, crash if necessary

Understanding error types is essential for choosing the right handling strategy

Operational Errors

Runtime problems that occur in correctly written programs. These are expected and should be handled gracefully—your application should recover and continue serving requests.

Programmer Errors

Bugs in your code that need to be fixed. These indicate logical errors and often leave your application in an unpredictable state. The safest approach is usually to crash and restart.

Creating Custom Error Classes

The built-in Error class is limited. Creating custom error classes allows you to add context, status codes, and distinguish between different error types programmatically.

errors/AppError.js
// Base application error class
class AppError extends Error {
  constructor(message, statusCode, isOperational = true) {
    super(message);
    
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = isOperational;
    this.timestamp = new Date().toISOString();
    
    // Capture stack trace, excluding constructor
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Now let's create specific error types for common scenarios:

errors/index.js
const AppError = require('./AppError');

// 400 Bad Request - Invalid input
class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message || 'Validation failed', 400);
    this.name = 'ValidationError';
    this.errors = errors; // Array of field-specific errors
  }
}

// 401 Unauthorized - Not authenticated
class AuthenticationError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401);
    this.name = 'AuthenticationError';
  }
}

// 403 Forbidden - Not authorized
class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403);
    this.name = 'ForbiddenError';
  }
}

// 404 Not Found
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
    this.name = 'NotFoundError';
  }
}

// 409 Conflict - Duplicate entry
class ConflictError extends AppError {
  constructor(message = 'Resource already exists') {
    super(message, 409);
    this.name = 'ConflictError';
  }
}

// 429 Too Many Requests
class RateLimitError extends AppError {
  constructor(retryAfter = 60) {
    super('Too many requests, please try again later', 429);
    this.name = 'RateLimitError';
    this.retryAfter = retryAfter;
  }
}

// 500 Internal Server Error
class InternalError extends AppError {
  constructor(message = 'Something went wrong') {
    super(message, 500);
    this.name = 'InternalError';
  }
}

// 503 Service Unavailable - External service down
class ServiceUnavailableError extends AppError {
  constructor(service = 'External service') {
    super(`${service} is temporarily unavailable`, 503);
    this.name = 'ServiceUnavailableError';
  }
}

module.exports = {
  AppError,
  ValidationError,
  AuthenticationError,
  ForbiddenError,
  NotFoundError,
  ConflictError,
  RateLimitError,
  InternalError,
  ServiceUnavailableError
};
Pro Tip

The isOperational flag helps your error handler distinguish between expected errors (that can be safely returned to users) and unexpected bugs (that should trigger alerts and possibly restart the process).

Handling Synchronous Errors

For synchronous code, the traditional try-catch block is your primary tool. However, there are patterns to make it more effective.

Synchronous Error Handling
const { ValidationError } = require('./errors');

function parseJSON(jsonString) {
  try {
    return JSON.parse(jsonString);
  } catch (error) {
    // Transform native error into our custom error
    throw new ValidationError('Invalid JSON format');
  }
}

function validateUser(userData) {
  const errors = [];
  
  if (!userData.email) {
    errors.push({ field: 'email', message: 'Email is required' });
  } else if (!isValidEmail(userData.email)) {
    errors.push({ field: 'email', message: 'Invalid email format' });
  }
  
  if (!userData.password || userData.password.length < 8) {
    errors.push({ 
      field: 'password', 
      message: 'Password must be at least 8 characters' 
    });
  }
  
  if (errors.length > 0) {
    throw new ValidationError('Validation failed', errors);
  }
  
  return true;
}

function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

Handling Asynchronous Errors

Asynchronous error handling is where many Node.js applications fail. With Promises and async/await, you have elegant patterns available, but you must use them correctly.

Using async/await with try-catch

Async Error Handling
const { NotFoundError, ServiceUnavailableError } = require('./errors');

async function getUser(userId) {
  try {
    const user = await db.users.findById(userId);
    
    if (!user) {
      throw new NotFoundError('User');
    }
    
    return user;
  } catch (error) {
    // Re-throw our custom errors
    if (error.isOperational) {
      throw error;
    }
    
    // Transform database errors
    if (error.code === 'ECONNREFUSED') {
      throw new ServiceUnavailableError('Database');
    }
    
    // Unknown error - let it bubble up
    throw error;
  }
}

// Usage in Express route
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await getUser(req.params.id);
    res.json(user);
  } catch (error) {
    next(error); // Pass to error handling middleware
  }
});

The asyncHandler Wrapper Pattern

Writing try-catch in every route handler is tedious. Create a wrapper to handle it automatically:

middleware/asyncHandler.js
/**
 * Wraps async route handlers to automatically catch errors
 * and pass them to Express error handling middleware
 */
const asyncHandler = (fn) => (req, res, next) => {
  Promise
    .resolve(fn(req, res, next))
    .catch(next);
};

module.exports = asyncHandler;

// Usage - no try-catch needed!
const asyncHandler = require('./middleware/asyncHandler');

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
}));

app.post('/users', asyncHandler(async (req, res) => {
  const user = await createUser(req.body);
  res.status(201).json(user);
}));
Error Flow with asyncHandler Request asyncHandler Route Handler Success Error Caught (.catch) next(error) Error Middleware (Centralized)

asyncHandler automatically catches errors and forwards them to Express error middleware

Centralized Error Handling Middleware

All roads lead to Rome—and all errors should lead to your centralized error handler. This pattern keeps error handling consistent and maintainable.

middleware/errorHandler.js
const { AppError } = require('../errors');
const logger = require('../utils/logger');

/**
 * Development error response - includes stack trace
 */
const sendErrorDev = (err, res) => {
  res.status(err.statusCode).json({
    status: err.status,
    error: err,
    message: err.message,
    stack: err.stack
  });
};

/**
 * Production error response - hides internal details
 */
const sendErrorProd = (err, res) => {
  // Operational, trusted error: send message to client
  if (err.isOperational) {
    res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
      ...(err.errors && { errors: err.errors }), // Include validation errors
      ...(err.retryAfter && { retryAfter: err.retryAfter })
    });
  } else {
    // Programming or unknown error: don't leak details
    logger.error('UNEXPECTED ERROR', err);
    
    res.status(500).json({
      status: 'error',
      message: 'Something went wrong. Please try again later.'
    });
  }
};

/**
 * Handle specific error types and convert to AppError
 */
const handleCastErrorDB = (err) => {
  return new AppError(`Invalid ${err.path}: ${err.value}`, 400);
};

const handleDuplicateFieldsDB = (err) => {
  const value = err.errmsg?.match(/(["'])(\\?.)*?\1/)?.[0];
  return new AppError(`Duplicate field value: ${value}`, 400);
};

const handleValidationErrorDB = (err) => {
  const errors = Object.values(err.errors).map(el => el.message);
  return new AppError(`Invalid input: ${errors.join('. ')}`, 400);
};

const handleJWTError = () => 
  new AppError('Invalid token. Please log in again.', 401);

const handleJWTExpiredError = () => 
  new AppError('Your token has expired. Please log in again.', 401);

/**
 * Global error handling middleware
 */
module.exports = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    sendErrorDev(err, res);
  } else {
    // Create a copy to avoid mutating original
    let error = { ...err, message: err.message, name: err.name };

    // Handle specific error types
    if (error.name === 'CastError') error = handleCastErrorDB(error);
    if (error.code === 11000) error = handleDuplicateFieldsDB(error);
    if (error.name === 'ValidationError') error = handleValidationErrorDB(error);
    if (error.name === 'JsonWebTokenError') error = handleJWTError();
    if (error.name === 'TokenExpiredError') error = handleJWTExpiredError();

    sendErrorProd(error, res);
  }
};

Integrating the Error Handler

app.js
const express = require('express');
const errorHandler = require('./middleware/errorHandler');
const { NotFoundError } = require('./errors');

const app = express();

// Body parsing middleware
app.use(express.json());

// Your routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);

// Handle 404 - Route not found
app.all('*', (req, res, next) => {
  next(new NotFoundError(`Route ${req.originalUrl}`));
});

// Global error handler - MUST be last
app.use(errorHandler);

module.exports = app;
Important

The error handling middleware must be the last middleware registered. Express identifies error-handling middleware by the four-parameter signature (err, req, res, next).

Handling Uncaught Exceptions and Rejections

Even with the best error handling, some errors slip through—uncaught exceptions and unhandled promise rejections. These are your safety net, and they need special attention in production.

server.js
const app = require('./app');
const logger = require('./utils/logger');

/**
 * Handle uncaught exceptions
 * These are synchronous errors that weren't caught
 * MUST be registered before any other code runs
 */
process.on('uncaughtException', (error) => {
  logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', {
    name: error.name,
    message: error.message,
    stack: error.stack
  });
  
  // Exit immediately - process is in undefined state
  process.exit(1);
});

const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
  logger.info(`Server running on port ${PORT}`);
});

/**
 * Handle unhandled promise rejections
 * These are async errors that weren't caught
 */
process.on('unhandledRejection', (reason, promise) => {
  logger.error('UNHANDLED REJECTION! 💥 Shutting down...', {
    reason: reason?.message || reason,
    stack: reason?.stack
  });
  
  // Graceful shutdown - finish existing requests first
  server.close(() => {
    process.exit(1);
  });
});

/**
 * Handle SIGTERM (e.g., from Kubernetes, Docker, Heroku)
 */
process.on('SIGTERM', () => {
  logger.info('SIGTERM received. Performing graceful shutdown...');
  
  server.close(() => {
    logger.info('Process terminated gracefully');
    process.exit(0);
  });
});
Graceful Shutdown Flow Signal Received SIGTERM / Error Stop Accepting New Connections Complete Existing Requests Close Exit(0) Cleanup Tasks During Shutdown Close database connections Flush logs to disk/service Clear job queues Deregister from load balancer

Graceful shutdown ensures no requests are lost during restarts or scaling

Error Logging Best Practices

Good logging is essential for debugging production issues. Here's a structured logging utility that works well with error handling:

utils/logger.js
const winston = require('winston');

const levels = {
  error: 0,
  warn: 1,
  info: 2,
  http: 3,
  debug: 4,
};

const colors = {
  error: 'red',
  warn: 'yellow',
  info: 'green',
  http: 'magenta',
  debug: 'blue',
};

winston.addColors(colors);

const format = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  winston.format.errors({ stack: true }), // Include stack trace
  winston.format.json()
);

const devFormat = winston.format.combine(
  winston.format.colorize({ all: true }),
  winston.format.timestamp({ format: 'HH:mm:ss' }),
  winston.format.printf(({ timestamp, level, message, ...meta }) => {
    let msg = `${timestamp} [${level}]: ${message}`;
    if (Object.keys(meta).length) {
      msg += ` ${JSON.stringify(meta, null, 2)}`;
    }
    return msg;
  })
);

const transports = [
  // Console output
  new winston.transports.Console({
    format: process.env.NODE_ENV === 'development' ? devFormat : format,
  }),
  
  // Error log file
  new winston.transports.File({
    filename: 'logs/error.log',
    level: 'error',
    format,
    maxsize: 5242880, // 5MB
    maxFiles: 5,
  }),
  
  // Combined log file
  new winston.transports.File({
    filename: 'logs/combined.log',
    format,
    maxsize: 5242880,
    maxFiles: 5,
  }),
];

const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
  levels,
  transports,
});

module.exports = logger;

Structured Error Logging

Logging Errors with Context
const logger = require('./utils/logger');

// Log with context for easier debugging
const logError = (error, req = null) => {
  const errorLog = {
    message: error.message,
    name: error.name,
    statusCode: error.statusCode,
    isOperational: error.isOperational,
    stack: error.stack,
  };

  if (req) {
    errorLog.request = {
      method: req.method,
      url: req.originalUrl,
      ip: req.ip,
      userId: req.user?.id,
      userAgent: req.get('user-agent'),
      body: sanitizeBody(req.body), // Remove sensitive fields
    };
  }

  logger.error('Request failed', errorLog);
};

// Remove sensitive data from logs
const sanitizeBody = (body) => {
  const sanitized = { ...body };
  const sensitiveFields = ['password', 'token', 'creditCard', 'ssn'];
  
  sensitiveFields.forEach(field => {
    if (sanitized[field]) {
      sanitized[field] = '[REDACTED]';
    }
  });
  
  return sanitized;
};

Database Error Handling

Database operations are a common source of errors. Here's how to handle them properly with different databases:

const { 
  NotFoundError, 
  ValidationError, 
  ConflictError 
} = require('./errors');

async function createUser(userData) {
  try {
    const user = await User.create(userData);
    return user;
  } catch (error) {
    // Handle duplicate key error (MongoDB code 11000)
    if (error.code === 11000) {
      const field = Object.keys(error.keyValue)[0];
      throw new ConflictError(
        `A user with this ${field} already exists`
      );
    }
    
    // Handle Mongoose validation errors
    if (error.name === 'ValidationError') {
      const errors = Object.values(error.errors).map(e => ({
        field: e.path,
        message: e.message
      }));
      throw new ValidationError('Validation failed', errors);
    }
    
    // Handle CastError (invalid ObjectId)
    if (error.name === 'CastError') {
      throw new ValidationError(
        `Invalid ${error.path}: ${error.value}`
      );
    }
    
    throw error;
  }
}
const { Sequelize } = require('sequelize');
const { 
  ValidationError, 
  ConflictError,
  ServiceUnavailableError 
} = require('./errors');

async function createUser(userData) {
  try {
    const user = await User.create(userData);
    return user;
  } catch (error) {
    // Unique constraint violation
    if (error instanceof Sequelize.UniqueConstraintError) {
      const field = error.errors[0]?.path;
      throw new ConflictError(
        `A user with this ${field} already exists`
      );
    }
    
    // Validation error
    if (error instanceof Sequelize.ValidationError) {
      const errors = error.errors.map(e => ({
        field: e.path,
        message: e.message
      }));
      throw new ValidationError('Validation failed', errors);
    }
    
    // Connection error
    if (error instanceof Sequelize.ConnectionError) {
      throw new ServiceUnavailableError('Database');
    }
    
    throw error;
  }
}
const { Prisma } = require('@prisma/client');
const { 
  NotFoundError, 
  ConflictError,
  ValidationError 
} = require('./errors');

async function createUser(userData) {
  try {
    const user = await prisma.user.create({
      data: userData
    });
    return user;
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      // Unique constraint violation
      if (error.code === 'P2002') {
        const field = error.meta?.target?.[0];
        throw new ConflictError(
          `A user with this ${field} already exists`
        );
      }
      
      // Record not found
      if (error.code === 'P2025') {
        throw new NotFoundError('Record');
      }
    }
    
    if (error instanceof Prisma.PrismaClientValidationError) {
      throw new ValidationError('Invalid data provided');
    }
    
    throw error;
  }
}

External API Error Handling

When calling external APIs, you need to handle network errors, timeouts, and API-specific errors:

services/externalApi.js
const axios = require('axios');
const { 
  ServiceUnavailableError, 
  RateLimitError,
  AppError 
} = require('../errors');

const apiClient = axios.create({
  baseURL: process.env.EXTERNAL_API_URL,
  timeout: 10000, // 10 seconds
  headers: {
    'Content-Type': 'application/json',
  },
});

// Response interceptor for error handling
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    // Network error or timeout
    if (!error.response) {
      if (error.code === 'ECONNABORTED') {
        throw new ServiceUnavailableError('Request timeout');
      }
      throw new ServiceUnavailableError('Network error');
    }

    const { status, data } = error.response;

    // Rate limiting
    if (status === 429) {
      const retryAfter = error.response.headers['retry-after'] || 60;
      throw new RateLimitError(parseInt(retryAfter));
    }

    // Service unavailable
    if (status >= 500) {
      throw new ServiceUnavailableError('External API');
    }

    // Client errors - pass through the message
    throw new AppError(
      data.message || 'External API error',
      status
    );
  }
);

// Retry logic with exponential backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await apiClient.get(url, options);
    } catch (error) {
      lastError = error;
      
      // Only retry on network errors or 5xx
      if (!error.isOperational || error.statusCode < 500) {
        throw error;
      }
      
      // Exponential backoff
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }
  
  throw lastError;
}

module.exports = { apiClient, fetchWithRetry };

Common Anti-Patterns to Avoid

Let's look at what NOT to do when handling errors in Node.js:

Anti-Pattern #1: Silent Error Swallowing

❌ Bad
// Error disappears into the void!
try {
  await riskyOperation();
} catch (error) {
  // Silently ignoring the error
}
✅ Good
try {
  await riskyOperation();
} catch (error) {
  logger.error('Operation failed', { error });
  throw error; // Or handle appropriately
}

Anti-Pattern #2: Using throw with strings

❌ Bad
// No stack trace, not an Error instance
throw 'User not found';
✅ Good
// Proper Error with stack trace
throw new NotFoundError('User');

Anti-Pattern #3: Not handling Promise rejections

❌ Bad
// Unhandled promise rejection!
getUserData(userId).then(user => {
  processUser(user);
});
✅ Good
// Handle the rejection
getUserData(userId)
  .then(user => processUser(user))
  .catch(error => handleError(error));

// Or use async/await with try-catch
try {
  const user = await getUserData(userId);
  processUser(user);
} catch (error) {
  handleError(error);
}

Anti-Pattern #4: Exposing internal errors to users

❌ Bad
// Leaking sensitive info!
res.status(500).json({
  error: error.message,
  stack: error.stack,
  query: 'SELECT * FROM users WHERE id = 123'
});
✅ Good
// Log internally, send generic message
logger.error('Database error', { error, query });
res.status(500).json({
  status: 'error',
  message: 'Something went wrong'
});

Production Error Handling Checklist

Before deploying to production, ensure your error handling meets these criteria:

Production Readiness Checklist

1

Custom Error Classes

Create hierarchy with operational flag, status codes, and proper stack traces.

2

Centralized Error Handler

Single middleware handling all errors consistently with dev/prod modes.

3

Async Handler Wrapper

Eliminate try-catch boilerplate in routes with asyncHandler pattern.

4

Process-Level Handlers

Handle uncaughtException, unhandledRejection, and SIGTERM signals.

5

Structured Logging

Log errors with context, sanitize sensitive data, use log aggregation.

6

External Service Handling

Implement timeouts, retries with backoff, and circuit breakers.

7

Error Monitoring

Integrate with Sentry, Datadog, or similar for real-time alerts.

Complete Error Handling Setup

Here's the recommended project structure for a production-ready error handling setup:

Project Structure
src/
├── errors/
│   ├── index.js          # Export all error classes
│   └── AppError.js       # Base error class
├── middleware/
│   ├── asyncHandler.js   # Async wrapper
│   └── errorHandler.js   # Centralized handler
├── utils/
│   └── logger.js         # Winston logger
├── app.js                # Express app setup
└── server.js             # Server + process handlers
Final Tip

Remember: fail fast, fail loud. It's better to crash early and recover than to continue in an undefined state. Use process managers like PM2 or container orchestrators to automatically restart your application when it crashes.

Summary

Robust error handling is what separates amateur Node.js applications from production-grade systems. Let's recap the key takeaways:

Concept Key Points
Error Types Distinguish between operational (handle) and programmer (fix) errors
Custom Errors Create a hierarchy with isOperational, statusCode, and proper stack traces
Async Errors Use asyncHandler wrapper to eliminate try-catch boilerplate
Centralized Handler Single error middleware with dev/prod modes and error transformation
Process Handlers Handle uncaughtException, unhandledRejection, SIGTERM
Logging Structured logs with context, sanitized sensitive data

Implementing these patterns will give you a solid foundation for building reliable Node.js applications. Your future self (and your ops team) will thank you when debugging production issues becomes straightforward rather than a nightmare.

Node.js Error Handling Best Practices Express Production Debugging
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about building robust, scalable applications. Writing about web development, best practices, and everything in between.