MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Node.js Error Handling Patterns You Should Know

Best practices for handling errors in Node.js: callbacks, promises, async/await, and Express middleware.

Node.jsError HandlingJavaScript

By MinhVo

Introduction

Error handling is the difference between a Node.js application that runs in development and one that survives production. The asynchronous, event-driven nature of Node.js makes error handling uniquely challenging compared to synchronous languages like Python or Java. A single unhandled error can crash your entire process, taking down every connected client with it.

Node.js applications face several categories of errors: operational errors like network timeouts and file-not-found conditions, programmer errors like null reference exceptions and type errors, and system errors like out-of-memory conditions. Each category requires a different handling strategy, and conflating them is the most common mistake developers make.

This guide covers every error handling pattern in Node.js, from the callback era through Promises and async/await, including Express middleware patterns, process-level error handling, and production monitoring strategies. By the end, you will have a comprehensive error handling architecture that catches errors at every layer of your application.

Error handling patterns

Understanding Error Types: Core Concepts

Operational Errors vs Programmer Errors

The most important distinction in error handling is between operational errors and programmer errors. Operational errors represent expected problems that your code must handle: a database query fails, a file does not exist, a network connection times out, or a user sends invalid input. These are not bugs. They are normal conditions that your application must anticipate and respond to gracefully.

Programmer errors are bugs. They represent mistakes in your code: reading a property of undefined, passing the wrong type to a function, or calling a function that does not exist. These should never happen in production, and the correct response is to crash and restart.

// Operational error: expected, handle gracefully
async function getUser(id: string) {
  try {
    const user = await db.users.findById(id);
    if (!user) {
      throw new AppError('USER_NOT_FOUND', `User ${id} not found`);
    }
    return user;
  } catch (err) {
    if (err instanceof AppError) {
      // Operational: return meaningful error to client
      throw err;
    }
    // Unexpected: database connection failed, etc.
    logger.error('Database error in getUser', { id, error: err });
    throw new AppError('INTERNAL_ERROR', 'Something went wrong');
  }
}
 
// Programmer error: should crash the process
function divide(a: number, b: number): number {
  if (b === 0) {
    // This should never happen if callers are correct
    throw new RangeError('Division by zero');
  }
  return a / b;
}

The Error Object

JavaScript Error objects contain a message, a stack trace, and optionally a code property. In Node.js, system errors also include an errno property and a syscall property that identify the specific system call that failed.

class AppError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly statusCode: number = 500,
    public readonly isOperational: boolean = true
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}
 
// Usage
throw new AppError('VALIDATION_ERROR', 'Email is required', 400);
throw new AppError('NOT_FOUND', 'User not found', 404);
throw new AppError('RATE_LIMITED', 'Too many requests', 429);

The Error-First Callback Pattern

Node.js adopted the error-first callback pattern as its standard asynchronous error handling mechanism. The first argument to every callback is an error (or null if successful), and subsequent arguments are the results.

import fs from 'fs';
 
// Error-first callback
fs.readFile('/path/to/file', 'utf-8', (err, data) => {
  if (err) {
    // Handle the error
    if (err.code === 'ENOENT') {
      console.error('File not found');
    } else {
      console.error('Read error:', err.message);
    }
    return;
  }
  console.log(data);
});

This pattern works but has a well-known problem: nested callbacks create callback hell, making code difficult to read and maintain. Error handling becomes scattered across multiple nested levels.

Error handling architecture

Architecture and Design Patterns

Promise-Based Error Handling

Promises improved error handling by providing a chainable API with a single catch point at the end of the chain.

import { readFile } from 'fs/promises';
 
// Promise chain
readFile('/path/to/file', 'utf-8')
  .then(data => JSON.parse(data))
  .then(config => initializeApp(config))
  .catch(err => {
    if (err.code === 'ENOENT') {
      console.error('Config file not found, using defaults');
      return initializeApp(defaultConfig);
    }
    console.error('Fatal error:', err);
    process.exit(1);
  });

The key insight with Promises is that errors propagate automatically through the chain until they hit a .catch(). Any error thrown in a .then() handler is caught by the next .catch() in the chain. This eliminates the scattered error handling of callbacks.

However, Promises have a critical pitfall: forgetting .catch() results in an unhandled promise rejection that may crash your process in newer Node.js versions.

Async/Await Error Handling

Async/await is syntactic sugar over Promises that makes asynchronous code look synchronous. Error handling uses familiar try/catch blocks.

import { readFile } from 'fs/promises';
 
async function loadConfig(): Promise<Config> {
  try {
    const data = await readFile('/path/to/config.json', 'utf-8');
    return JSON.parse(data);
  } catch (err) {
    if (err.code === 'ENOENT') {
      console.warn('Config file not found, using defaults');
      return defaultConfig;
    }
    throw err; // Re-throw unexpected errors
  }
}

The async/await pattern has a subtle but critical issue: if you forget the try/catch block, the error becomes an unhandled promise rejection. This is why many developers wrap async functions in a utility.

// Utility to wrap async functions for Express
type AsyncHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise<void>;
 
function asyncHandler(fn: AsyncHandler) {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
}
 
// Usage in Express
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
}));

Error Hierarchy

A well-structured error hierarchy makes error handling consistent across your application.

// Base application error
class AppError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly statusCode: number = 500,
    public readonly isOperational: boolean = true
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}
 
// Specific error types
class ValidationError extends AppError {
  constructor(message: string, public readonly fields?: Record<string, string>) {
    super('VALIDATION_ERROR', message, 400);
  }
}
 
class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super('NOT_FOUND', `${resource} with id ${id} not found`, 404);
  }
}
 
class AuthenticationError extends AppError {
  constructor(message: string = 'Authentication required') {
    super('UNAUTHORIZED', message, 401);
  }
}
 
class AuthorizationError extends AppError {
  constructor(message: string = 'Insufficient permissions') {
    super('FORBIDDEN', message, 403);
  }
}
 
class ConflictError extends AppError {
  constructor(message: string) {
    super('CONFLICT', message, 409);
  }
}
 
class RateLimitError extends AppError {
  constructor(retryAfter: number) {
    super('RATE_LIMITED', `Too many requests. Retry after ${retryAfter}s`, 429);
  }
}

Step-by-Step Implementation

Express Error Handling Middleware

Express has a built-in error handling mechanism through error middleware functions. These functions have four parameters: err, req, res, and next.

import express, { Request, Response, NextFunction } from 'express';
 
const app = express();
 
// Regular routes
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 { email, name } = req.body;
  if (!email) {
    throw new ValidationError('Email is required', { email: 'required' });
  }
  const user = await createUser({ email, name });
  res.status(201).json(user);
}));
 
// Error handling middleware (must be after all routes)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  // Log the error
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}:`, {
    error: err.message,
    stack: err.stack,
    requestId: req.headers['x-request-id'],
  });
 
  // Handle known operational errors
  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err instanceof ValidationError && { fields: err.fields }),
      },
    });
  }
 
  // Unknown errors: don't leak internal details
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    },
  });
});

Database Error Handling

Database operations are a common source of operational errors. Each database driver has its own error types that you need to handle.

import { Prisma } from '@prisma/client';
 
async function createUser(data: CreateUserInput) {
  try {
    return await prisma.user.create({ data });
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
      switch (err.code) {
        case 'P2002':
          throw new ConflictError(
            `User with this ${err.meta?.target} already exists`
          );
        case 'P2003':
          throw new ValidationError('Referenced record not found');
        case 'P2025':
          throw new NotFoundError('Record', 'specified');
        default:
          throw new AppError('DATABASE_ERROR', err.message, 500);
      }
    }
    if (err instanceof Prisma.PrismaClientValidationError) {
      throw new ValidationError('Invalid data provided');
    }
    throw err; // Re-throw unexpected errors
  }
}

External API Error Handling

When calling external APIs, you must handle network errors, timeouts, and unexpected response formats.

import axios, { AxiosError } from 'axios';
 
class ExternalServiceError extends AppError {
  constructor(
    service: string,
    message: string,
    public readonly originalError?: Error
  ) {
    super('EXTERNAL_SERVICE_ERROR', `${service}: ${message}`, 502);
  }
}
 
async function callPaymentService(order: Order) {
  try {
    const response = await axios.post('https://payments.example.com/charge', {
      amount: order.total,
      currency: order.currency,
    }, {
      timeout: 10000,
      headers: { 'X-Idempotency-Key': order.id },
    });
    return response.data;
  } catch (err) {
    if (err instanceof AxiosError) {
      if (err.code === 'ECONNABORTED') {
        throw new ExternalServiceError('PaymentService', 'Request timeout');
      }
      if (err.response) {
        throw new ExternalServiceError(
          'PaymentService',
          `HTTP ${err.response.status}: ${err.response.data?.message}`
        );
      }
      if (err.request) {
        throw new ExternalServiceError('PaymentService', 'No response received');
      }
    }
    throw new ExternalServiceError('PaymentService', 'Unknown error', err as Error);
  }
}

File System Error Handling

import fs from 'fs/promises';
 
async function readConfigFile(path: string): Promise<Config> {
  try {
    const data = await fs.readFile(path, 'utf-8');
    return JSON.parse(data);
  } catch (err: any) {
    switch (err.code) {
      case 'ENOENT':
        throw new AppError('CONFIG_NOT_FOUND', `Config file ${path} not found`, 500);
      case 'EACCES':
        throw new AppError('CONFIG_ACCESS_DENIED', `Cannot read ${path}`, 500);
      case 'EISDIR':
        throw new AppError('CONFIG_IS_DIRECTORY', `${path} is a directory`, 500);
      default:
        if (err instanceof SyntaxError) {
          throw new AppError('CONFIG_INVALID_JSON', `Invalid JSON in ${path}`, 500);
        }
        throw err;
    }
  }
}

Real-World Use Cases

Use Case 1: API Error Response Standardization

A REST API needs consistent error responses across all endpoints. This middleware catches all errors and formats them uniformly.

// Standard error response format
interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Record<string, any>;
    requestId?: string;
  };
}
 
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  const requestId = req.headers['x-request-id'] as string;
  
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err instanceof ValidationError && { details: err.fields }),
        requestId,
      },
    });
  }
  
  // Log unexpected errors with full context
  logger.error({
    message: err.message,
    stack: err.stack,
    requestId,
    method: req.method,
    url: req.url,
    body: req.body,
  });
  
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId,
    },
  });
});

Use Case 2: Graceful Shutdown

When your process receives a termination signal, you need to close all connections and finish in-flight requests before exiting.

import http from 'http';
 
const server = http.createServer(app);
 
async function gracefulShutdown(signal: string) {
  console.log(`Received ${signal}. Starting graceful shutdown...`);
  
  // Stop accepting new connections
  server.close(() => {
    console.log('HTTP server closed');
  });
  
  // Close database connections
  await prisma.$disconnect();
  console.log('Database disconnected');
  
  // Close Redis connections
  await redis.quit();
  console.log('Redis disconnected');
  
  // Close message queue connections
  await mq.close();
  console.log('Message queue closed');
  
  process.exit(0);
}
 
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
 
// Set a timeout for graceful shutdown
setTimeout(() => {
  console.error('Graceful shutdown timed out. Forcing exit.');
  process.exit(1);
}, 30000);

Use Case 3: Uncaught Exception Handling

Process-level error handlers catch errors that escape all other handling. These are last-resort safety nets, not error handling strategies.

// Unhandled promise rejections
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
  logger.fatal({
    message: 'Unhandled promise rejection',
    reason: reason instanceof Error ? reason.stack : reason,
  });
  // In production, crash and let the process manager restart
  process.exit(1);
});
 
// Uncaught synchronous exceptions
process.on('uncaughtException', (error: Error) => {
  logger.fatal({
    message: 'Uncaught exception',
    error: error.message,
    stack: error.stack,
  });
  // Always crash on uncaught exceptions
  process.exit(1);
});

Best Practices for Production

  1. Distinguish operational from programmer errors: Operational errors should be handled and returned to the client. Programmer errors should crash the process so the bug is detected and fixed.

  2. Use a custom error class hierarchy: Create specific error classes for each category of error in your application. This makes error handling in middleware clean and consistent.

  3. Never swallow errors silently: Always log errors, even if you handle them gracefully. Silent failures make debugging impossible.

  4. Use asyncHandler for Express routes: Wrapping async route handlers prevents unhandled promise rejections from crashing your process.

  5. Implement centralized error logging: Use a structured logger like Pino or Winston that outputs JSON logs suitable for log aggregation tools like ELK or Datadog.

  6. Add request context to error logs: Include the request ID, user ID, URL, method, and body in error logs for debugging context.

  7. Set up process-level error handlers: Always listen for uncaughtException and unhandledRejection events as a last resort safety net.

  8. Implement graceful shutdown: Close all connections and finish in-flight requests before exiting when receiving SIGTERM or SIGINT.

Common Pitfalls and Solutions

PitfallImpactSolution
Forgetting asyncHandler in ExpressUnhandled promise rejection crashes processWrap all async routes with asyncHandler
Swallowing errors with empty catchSilent failures, impossible debuggingAlways log or re-throw caught errors
Leaking internal error details to clientsSecurity vulnerabilityReturn generic messages for 500 errors
Not handling Promise.all rejectionsOne failed promise hides other resultsUse Promise.allSettled or individual try/catch
Throwing strings instead of Error objectsMissing stack traces and error metadataAlways throw Error instances
Catching errors too broadlyHiding bugs in try/catch blocksCatch specific error types, re-throw unexpected ones
Not setting up process error handlersSilent crashes with no logsAdd uncaughtException and unhandledRejection handlers

Performance Optimization

Structured Logging

Use a fast structured logger like Pino for production error logging. JSON output is machine-readable and works well with log aggregation tools.

import pino from 'pino';
 
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
});
 
// In error middleware
logger.error({
  err,
  req: {
    method: req.method,
    url: req.url,
    requestId: req.headers['x-request-id'],
  },
  userId: req.user?.id,
}, 'Request failed');

Error Monitoring with Sentry

Integrate error monitoring to catch and track production errors automatically.

import * as Sentry from '@sentry/node';
 
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1,
});
 
// Capture errors in Express middleware
app.use(Sentry.Handlers.errorHandler());
 
// Or manually
try {
  await riskyOperation();
} catch (err) {
  Sentry.captureException(err, {
    tags: { feature: 'payment' },
    user: { id: user.id, email: user.email },
  });
  throw err;
}

Testing Strategies

Test your error handling code to ensure errors are caught and formatted correctly.

import request from 'supertest';
import { app } from '../app';
 
describe('Error handling', () => {
  it('returns 404 for missing user', async () => {
    const res = await request(app)
      .get('/users/nonexistent')
      .expect(404);
    
    expect(res.body.error.code).toBe('NOT_FOUND');
    expect(res.body.error.message).toContain('nonexistent');
  });
  
  it('returns 400 for validation errors', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'Test' }) // Missing email
      .expect(400);
    
    expect(res.body.error.code).toBe('VALIDATION_ERROR');
    expect(res.body.error.fields.email).toBe('required');
  });
  
  it('returns 500 with generic message for unexpected errors', async () => {
    // Mock a database failure
    jest.spyOn(prisma.user, 'findUnique').mockRejectedValue(
      new Error('Connection lost')
    );
    
    const res = await request(app)
      .get('/users/1')
      .expect(500);
    
    expect(res.body.error.code).toBe('INTERNAL_ERROR');
    expect(res.body.error.message).not.toContain('Connection lost');
  });
});

Future Outlook

Node.js error handling continues to evolve. The AbortController API provides a standardized way to cancel asynchronous operations, which is important for handling timeouts in fetch requests and other async patterns. The Error.cause property (available since Node.js 16) allows chaining errors to preserve the original error context.

Deno and Bun runtimes are adopting stricter default error handling, with Deno crashing on unhandled rejections by default. This reflects the industry consensus that unhandled errors should be loud, not silent.

Conclusion

Error handling in Node.js requires a multi-layered approach: custom error classes for classification, async/await with try/catch for individual operations, Express middleware for centralized handling, and process-level handlers as a safety net.

Key takeaways:

  1. Distinguish operational errors from programmer errors
  2. Use a custom error class hierarchy with HTTP status codes
  3. Wrap async Express routes with asyncHandler to prevent unhandled rejections
  4. Implement centralized error logging with structured JSON output
  5. Never leak internal error details to clients in production
  6. Set up process-level error handlers as a last resort safety net
  7. Implement graceful shutdown to close connections cleanly
  8. Test your error handling code as thoroughly as your business logic

Build your error handling architecture from the bottom up: process handlers catch everything that escapes, Express middleware catches everything that escapes route handlers, and route handlers catch errors from individual operations. This layered approach ensures no error goes unnoticed and every error is handled appropriately.