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

Node.js Error Handling Best Practices

Mayur Dabhi
Mayur Dabhi
March 29, 2026
24 min read

A request hangs until the client times out. Or the API returns a 500 with a raw stack trace that includes a SQL query. Or the process just dies at 3am and the only log line is UnhandledPromiseRejection. Most of these come from the same place: errors that were never planned for, so each route deals with them differently or not at all.

This post walks through the setup I'd put in any Express app: separating operational errors from bugs, custom error classes, catching async errors without try-catch in every route, one central error middleware, process-level handlers with graceful shutdown, and logging that actually helps when something breaks.

What's covered
  • 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

Not every error deserves the same response. A user typing a bad email and a TypeError in your own code need very different handling, so it helps to sort errors into two buckets first.

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

Operational errors get handled; programmer errors get fixed

Operational Errors

Runtime problems that occur in correctly written programs. You know they'll happen, so handle them: return a sensible response and keep 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

A plain Error only carries a message and a stack. Once you need an HTTP status code or a way to tell "expected" from "bug" in your middleware, a small class hierarchy pays off.

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;

On top of that, one subclass per status code you actually return:

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
};
Why isOperational matters

The error handler checks isOperational to decide what to do. If it's true, the message is safe to show the user. If it's missing, you're looking at a bug: log it, alert someone, and send a generic 500.

Handling Synchronous Errors

Sync code is the easy case: plain try-catch. The useful habit is to catch low-level errors (like a SyntaxError from JSON.parse) and rethrow them as one of your own classes, so the middleware knows what status to send.

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

This is where most Node.js apps get it wrong. An error thrown inside an async function becomes a rejected promise, and if nothing awaits it with a catch, it never reaches your error handler.

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 gets old fast, and sooner or later someone forgets one. A tiny wrapper does it for you:

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 catches the rejection and forwards it to the Express error middleware

Express 4 vs Express 5

Express 4 does not catch rejected promises from async handlers. Without a wrapper (or the express-async-errors package), the request just hangs until the client times out, and Node logs an unhandled rejection. Express 5 forwards rejected promises to next() on its own, so check which version you're on before adding the wrapper everywhere.

Centralized Error Handling Middleware

Every error should end up in one place. That way the response format, the logging, and the decision about what to hide from users live in a single file instead of being copied across routes.

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

Register the error middleware last. Express recognizes it only by its four parameters, (err, req, res, next), so don't drop the unused next argument (a linter cleanup can do this for you). With three parameters Express treats it as a normal middleware and your errors fall through to the default HTML error page.

Handling Uncaught Exceptions and Rejections

Some errors will still get past everything above. These process-level handlers are the last line: log what happened, then shut down cleanly. Since Node 15, an unhandled rejection crashes the process by default anyway, so it's better to control how that happens.

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 lets in-flight requests finish before the process exits

Error Logging Best Practices

When something breaks in production, the logs are usually all you have. JSON logs with timestamps and stack traces are much easier to search than console.log output. Here's a Winston setup that does that:

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

Each ORM throws its own error shapes. The job is the same in all of them: turn duplicate keys, validation failures and lost connections into your own error classes.

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

Third-party APIs fail in three main ways: the network drops, the request times out, or the API returns an error status. Always set a timeout. Axios has no timeout by default, so a slow upstream can hold your request open indefinitely.

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

These show up in code reviews all the time:

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 shipping, I'd go through this list:

Production Readiness Checklist

1

Custom Error Classes

A small hierarchy with an 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

No try-catch boilerplate in routes (or Express 5, which handles it for you).

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

Timeouts on every outbound call, retries with backoff, and a circuit breaker for flaky dependencies.

7

Error Monitoring

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

Complete Error Handling Setup

Here's how the files from this post fit together:

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
Let it crash, but have something restart it

Exiting on an uncaught exception only works if something brings the process back. Run under PM2, systemd, or a container orchestrator with a restart policy. Otherwise the "safe" crash just turns into downtime.

When I'd use all of this (and when I wouldn't)

The full setup makes sense for an API with several developers, real users, and an on-call rotation. For a small internal tool or a weekend project, a lot of it is ceremony. Here's what each piece costs and when I'd leave it out:

Pattern When I'd skip or simplify it
Error Types Never skip the distinction itself. It decides whether you show a message or page someone.
Custom Errors Eight classes is too many for a small app. AppError with a status code covers most cases.
Async Errors On Express 5 you don't need asyncHandler at all.
Centralized Handler Always worth it. Just keep the DB and JWT mapping list short, or it turns into a dumping ground.
Process Handlers Don't use uncaughtException to keep the process alive. Log and exit, nothing more.
Logging In containers, skip the log files and write JSON to stdout. Let the platform collect it.

The mistake I see most often isn't missing classes or middleware. It's a catch block that logs and returns null, so the caller carries on with bad data and the real failure shows up three functions later. If you're not sure what to do with an error, rethrow it and let the central handler decide.

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.