DEV Community

Alex Chen
Alex Chen

Posted on

Error Handling in Node.js: Beyond Try/Catch (2026)

Error Handling in Node.js: Beyond Try/Catch (2026)

Most developers stop at try/catch. Here's the complete error handling system I use in production.

The Problem with Basic Error Handling

// ❌ This is what most code looks like
app.get('/api/users/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    res.json(user);
  } catch (err) {
    console.error(err); // Lost in a sea of logs
    res.status(500).json({ error: 'Something went wrong' }); // Useless message
  }
});

// Problems:
// 1. No error classification — everything is "something went wrong"
// 2. No context — which user? Which request?
// 3. No recovery strategy — can the client retry?
// 4. Leaks internals in development, hides them in production inconsistently
Enter fullscreen mode Exit fullscreen mode

Step 1: Custom Error Classes

// src/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.code = code;           // Machine-readable error code
    this.isOperational = true;  // Distinguishes from programming errors
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      error: {
        code: this.code,
        message: this.message,
        ...(this.details && { details: this.details }),
        ...(this.field && { field: this.field }),
      }
    };
  }
}

// Predefined error types
class NotFoundError extends AppError {
  constructor(resource = 'Resource', id) {
    super(`${resource} not found${id ? ` (id: ${id})` : ''}`, 404, 'NOT_FOUND');
    this.resource = resource;
  }
}

class ValidationError extends AppError {
  constructor(details = []) {
    super('Validation failed', 422, 'VALIDATION_ERROR');
    this.details = details;     // Array of { field, issue }
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403, 'FORBIDDEN');
  }
}

class ConflictError extends AppError {
  constructor(resource, field) {
    super(
      `${resource} already exists`,
      409,
      'CONFLICT'
    );
    this.resource = resource;
    this.field = field;
  }
}

class RateLimitError extends AppError {
  constructor(retryAfterSeconds = 60) {
    super('Too many requests', 429, 'RATE_LIMITED');
    this.retryAfter = retryAfterSeconds;
  }
}

module.exports = {
  AppError,
  NotFoundError,
  ValidationError,
  UnauthorizedError,
  ForbiddenError,
  ConflictError,
  RateLimitError,
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Centralized Error Handler Middleware

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

function errorHandler(err, req, res, _next) {
  const requestId = req.id || req.headers['x-request-id'];

  // Default values for unexpected errors
  let statusCode = err.statusCode || 500;
  let errorCode = err.code || 'INTERNAL_ERROR';
  let message = err.message || 'An internal error occurred';

  // Log ALL errors with context
  const logData = {
    requestId,
    method: req.method,
    path: req.path,
    ip: req.ip,
    userId: req.user?.id,
    errorName: err.name,
    errorMessage: message,
    errorCode,
    statusCode,
    stack: err.stack,
  };

  if (err.isOperational) {
    // Expected business logic error → info level
    console.info(JSON.stringify(logData));
  } else {
    // Unexpected/programming error → error + alert
    console.error(JSON.stringify(logData));

    // In production, don't leak internals
    if (process.env.NODE_ENV === 'production') {
      statusCode = 500;
      errorCode = 'INTERNAL_ERROR';
      message = 'An internal error occurred';

      // TODO: Send to Sentry/DataDog/etc here
      // captureException(err, { requestId });
    }
  }

  // Build response
  const response = {
    error: {
      code: errorCode,
      message,
    },
  };

  // Include request ID for support lookup
  if (requestId) {
    response.requestId = requestId;
  }

  // Include retry info for rate limits / service unavailable
  if (err.retryAfter) {
    response.retryAfter = err.retryAfter;
  }

  // Include details for validation errors
  if (err.details) {
    response.error.details = err.details;
  }

  // Send response
  res.status(statusCode).json(response);
}

// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', JSON.stringify({
    reason: reason?.message || String(reason),
    stack: reason?.stack,
  }));

  // Don't crash in production, but do in test
  if (process.env.NODE_ENV === 'test') {
    throw reason;
  }
});

// Handle uncaught exceptions (last resort)
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', JSON.stringify({
    message: err.message,
    stack: err.stack,
  }));

  // Unrecoverable — must exit
  // Use a graceful shutdown instead of process.exit(1)
  gracefulShutdown('UNCAUGHT_EXCEPTION');
});

module.exports = errorHandler;
Enter fullscreen mode Exit fullscreen mode

Step 3: Using It in Controllers

// src/controllers/userController.js
const { NotFoundError, ValidationError, ConflictError } = require('../errors');
const userService = require('../services/userService');

exports.getUser = async (req, res, next) => {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) throw new NotFoundError('User', req.params.id);
    res.json({ data: user });
  } catch (err) {
    next(err); // Pass to centralized handler
  }
};

exports.createUser = async (req, res, next) => {
  try {
    const { email, name, password } = req.body;

    // Validation
    const errors = [];
    if (!email) errors.push({ field: 'email', issue: 'Email is required' });
    if (!name) errors.push({ field: 'name', issue: 'Name is required' });
    if (!password) errors.push({ field: 'password', issue: 'Password is required' });
    else if (password.length < 8) errors.push({ field: 'password', issue: 'Must be 8+ characters' });

    if (errors.length > 0) throw new ValidationError(errors);

    // Business rule check
    const existing = await userService.findByEmail(email);
    if (existing) throw new ConflictError('User', 'email');

    const user = await userService.create({ email, name, password });
    res.status(201).json({ data: user });
  } catch (err) {
    next(err);
  }
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Async Wrapper (Eliminates Try/Catch Boilerplate)

// src/middleware/asyncHandler.js
// Wraps async route handlers to forward errors to Express error middleware

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;
Enter fullscreen mode Exit fullscreen mode

Now your controllers become clean:

// BEFORE (verbose):
exports.getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw new NotFoundError('User');
    res.json(user);
  } catch (err) {
    next(err);
  }
};

// AFTER (clean):
exports.getUser = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Input Validation Middleware

// src/middleware/validate.js
const { ValidationError } = require('../errors');

// Validation rules schema
function validate(rules) {
  return (req, res, next) => {
    const errors = [];
    const source = { ...req.body, ...req.query, ...req.params };

    for (const [field, fieldRules] of Object.entries(rules)) {
      const value = source[field];

      for (const rule of fieldRules) {
        if (rule.required && (value === undefined || value === null)) {
          errors.push({ field, issue: `${field} is required` });
          break; // Skip other rules for missing fields
        }

        if (value === undefined || value === null) continue; // Optional field not provided

        if (rule.type === 'string' && typeof value !== 'string') {
          errors.push({ field, issue: `Must be a string` });
        } else if (rule.type === 'number' && typeof value !== 'number') {
          errors.push({ field, issue: `Must be a number` });
        } else if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
          errors.push({ field, issue: `Invalid email format` });
        } else if (rule.minLength && typeof value === 'string' && value.length < rule.minLength) {
          errors.push({ field, issue: `Must be at least ${rule.minLength} characters` });
        } else if (rule.maxLength && typeof value === 'string' && value.length > rule.maxLength) {
          errors.push({ field, issue: `Must be no more than ${rule.maxLength} characters` });
        } else if (rule.min !== undefined && typeof value === 'number' && value < rule.min) {
          errors.push({ field, issue: `Must be >= ${rule.min}` });
        } else if (rule.max !== undefined && typeof value === 'number' && value > rule.max) {
          errors.push({ field, issue: `Must be <= ${rule.max}` });
        } else if (rule.enum && !rule.enum.includes(value)) {
          errors.push({ field, issue: `Must be one of: ${rule.enum.join(', ')}` });
        } else if (rule.pattern && !rule.pattern.test(value)) {
          errors.push({ field, issue: `Invalid format` });
        } else if (rule.custom && typeof rule.custom === 'function') {
          const customError = rule.custom(value, source);
          if (customError) errors.push({ field, issue: customError });
        }
      }
    }

    if (errors.length > 0) {
      return next(new ValidationError(errors));
    }

    next();
  };
}

// Usage:
app.post('/api/users',
  validate({
    email: [
      { type: 'email', required: true },
      { maxLength: 255 },
    ],
    name: [
      { type: 'string', required: true },
      { minLength: 2 },
      { maxLength: 100 },
    ],
    password: [
      { type: 'string', required: true },
      { minLength: 8 },
      { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, message: 'Must contain uppercase, lowercase, and number' },
    ],
    role: [
      { enum: ['user', 'admin'] }, // optional, defaults if not sent
    ],
  }),
  userController.createUser
);
Enter fullscreen mode Exit fullscreen mode

Step 6: API Response Standardization

// src/utils/response.js

class ApiResponse {
  static success(res, data, meta = {}) {
    return res.status(200).json({
      success: true,
      data,
      ...meta,
    });
  }

  static created(res, data) {
    return res.status(201).json({
      success: true,
      data,
    });
  }

  static noContent(res) {
    return res.status(204).send();
  }

  static paginated(res, items, page, limit, total) {
    return res.status(200).json({
      success: true,
      data: items,
      pagination: {
        page: Number(page),
        limit: Number(limit),
        total,
        totalPages: Math.ceil(total / limit),
        hasNext: page * limit < total,
        hasPrev: page > 1,
      },
    });
  }

  static error(res, error) {
    return res.status(error.statusCode || 500).json(error.toJSON());
  }
}

module.exports = ApiResponse;
Enter fullscreen mode Exit fullscreen mode

What the Client Sees

Success Response

{
  "success": true,
  "data": {
    "id": "usr_abc123",
    "email": "user@example.com",
    "name": "John Doe"
  }
}
Enter fullscreen mode Exit fullscreen mode

Paginated Response

{
  "success": true,
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 150,
    "totalPages": 8,
    "hasNext": true,
    "hasPrev": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Error Responses

// Not Found
{ "error": { "code": "NOT_FOUND", "message": "User not found (id: xyz)" } }

// Validation Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      { "field": "email", "issue": "Invalid email format" },
      { "field": "password", "issue": "Must be 8+ characters" }
    ]
  }
}

// Internal Error (with request ID for support)
{
  "error": { "code": "INTERNAL_ERROR", "message": "An internal error occurred" },
  "requestId": "req_abc123"
}
Enter fullscreen mode Exit fullscreen mode

Graceful Shutdown

let isShuttingDown = false;

function gracefulShutdown(reason = 'SIGTERM') {
  if (isShuttingDown) return;
  isShuttingDown = true;

  console.log(`\n[${reason}] Starting graceful shutdown...`);

  // Stop accepting new connections
  server.close(() => {
    console.log('[shutdown] HTTP server closed');
    process.exit(0);
  });

  // Force exit after timeout (e.g., 10 seconds)
  setTimeout(() => {
    console.error('[shutdown] Forced exit after timeout');
    process.exit(1);
  }, 10_000);
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Enter fullscreen mode Exit fullscreen mode

Testing Your Error Handling

// tests/unit/errors.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { NotFoundError, ValidationError, AppError } from '../src/errors/index.js';

describe('NotFoundError', () => {
  it('should have correct status code and message', () => {
    const err = new NotFoundError('User', '123');
    assert.equal(err.statusCode, 404);
    assert.equal(err.code, 'NOT_FOUND');
    assert.equal(err.message, 'User not found (id: 123)');
    assert.equal(err.isOperational, true);
  });

  it('should serialize to JSON correctly', () => {
    const err = new NotFoundError('Post');
    const json = err.toJSON();
    assert.equal(json.error.code, 'NOT_FOUND');
    assert.ok(json.error.message.includes('Post'));
  });
});

describe('ValidationError', () => {
  it('should include details array', () => {
    const err = new ValidationError([
      { field: 'email', issue: 'Required' },
      { field: 'name', issue: 'Required' },
    ]);
    assert.equal(err.statusCode, 422);
    assert.equal(err.details.length, 2);
  });
});
Enter fullscreen mode Exit fullscreen mode

How do you handle errors? Still using basic try/catch or have you built something more robust?

Follow @armorbreak for more practical Node.js guides.

Top comments (0)