ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeError Handling Architecture in Node.js: Stop Letting Exceptions Run Your App

Error Handling Architecture in Node.js: Stop Letting Exceptions Run Your App

A typed error hierarchy, one central handler, and the asyncHandler wrapper that eliminates try-catch from every route

#Node.js error handling#Express error middleware#TypeScript error classes#AppError Node.js#unhandledRejection Node.js#asyncHandler Express#error handling architecture 2026#Zod error handling Express
Z
ZyVOP

Senior Developer

May 26, 2026
9 min read
12 views
Error Handling Architecture in Node.js: Stop Letting Exceptions Run Your App

Most Node.js apps handle errors the same way: a try-catch here, an Express error middleware at the bottom, maybe a process.on('uncaughtException') added after a scary incident. The result is inconsistent — some errors return structured JSON, some return HTML stack traces, some cause the process to exit silently, and the ones that slip through become 2 AM incidents.

Good error handling is not about catching every exception. It is about building a system where errors are classified, handled consistently, communicated clearly to clients, logged with enough context to debug, and never allowed to crash the process unexpectedly.

This guide builds that system from scratch.


Step 1 — A Typed Error Hierarchy

The foundation is a set of error classes that carry semantic meaning. The difference between a 404 Not Found and a 403 Forbidden and a 500 Internal Server Error is not just a status code — it represents different situations that need different handling at every layer.

// src/lib/errors.ts

export class AppError extends Error {
  constructor(
    public readonly message:    string,
    public readonly statusCode: number,
    public readonly code:       string,       // Machine-readable error code
    public readonly isOperational: boolean = true,  // Expected vs unexpected
    public readonly details?: unknown,        // Additional context
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 400 — Client sent bad data
export class ValidationError extends AppError {
  constructor(message: string, details?: unknown) {
    super(message, 400, 'VALIDATION_ERROR', true, details);
  }
}

// 401 — Not authenticated
export class AuthenticationError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'AUTHENTICATION_ERROR', true);
  }
}

// 403 — Authenticated but not allowed
export class AuthorizationError extends AppError {
  constructor(message = 'Insufficient permissions') {
    super(message, 403, 'AUTHORIZATION_ERROR', true);
  }
}

// 404 — Resource does not exist
export class NotFoundError extends AppError {
  constructor(resource: string, id?: string) {
    const message = id
      ? `${resource} with id '${id}' not found`
      : `${resource} not found`;
    super(message, 404, 'NOT_FOUND', true, { resource, id });
  }
}

// 409 — State conflict (duplicate, already exists, etc.)
export class ConflictError extends AppError {
  constructor(message: string, details?: unknown) {
    super(message, 409, 'CONFLICT', true, details);
  }
}

// 422 — Valid format but business logic rejected it
export class UnprocessableError extends AppError {
  constructor(message: string, details?: unknown) {
    super(message, 422, 'UNPROCESSABLE', true, details);
  }
}

// 429 — Rate limited
export class RateLimitError extends AppError {
  constructor(retryAfter?: number) {
    super('Too many requests', 429, 'RATE_LIMIT_EXCEEDED', true, { retryAfter });
  }
}

// 503 — Dependency unavailable (DB down, third-party timeout)
export class ServiceUnavailableError extends AppError {
  constructor(service: string) {
    super(`${service} is temporarily unavailable`, 503, 'SERVICE_UNAVAILABLE', true, { service });
  }
}

// Helper to check if an error is one of ours
export function isAppError(error: unknown): error is AppError {
  return error instanceof AppError;
}

// Helper to check if an error is operational (expected)
export function isOperationalError(error: unknown): boolean {
  return isAppError(error) && error.isOperational;
}

Step 2 — The Central Error Handler

One middleware handles all errors. No duplicated logic. Consistent response shape everywhere.

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { DatabaseError } from 'pg';
import { AppError, ValidationError, isAppError } from '../lib/errors';
import logger from '../lib/logger';
import * as Sentry from '@sentry/node';

// Standard error response shape — always the same structure
interface ErrorResponse {
  error: {
    message:   string;
    code:      string;
    requestId: string;
    details?:  unknown;
  };
}

export function errorHandler(
  err: unknown,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  // Already sent a response — cannot send another
  if (res.headersSent) {
    next(err);
    return;
  }

  let appError: AppError;

  // ── Normalize all errors into AppError ──────────────────

  if (isAppError(err)) {
    // Already one of ours
    appError = err;

  } else if (err instanceof ZodError) {
    // Zod validation failure
    appError = new ValidationError(
      'Validation failed',
      err.flatten().fieldErrors
    );

  } else if (err instanceof DatabaseError) {
    // Postgres errors
    if (err.code === '23505') {
      // Unique violation
      appError = new AppError(
        'A record with this value already exists',
        409, 'DUPLICATE_ENTRY', true,
        { constraint: err.constraint }
      );
    } else if (err.code === '23503') {
      // Foreign key violation
      appError = new AppError(
        'Referenced record does not exist',
        422, 'FOREIGN_KEY_VIOLATION', true
      );
    } else {
      // Unknown DB error — treat as unexpected
      appError = new AppError(
        'A database error occurred',
        500, 'DATABASE_ERROR', false
      );
    }

  } else if (err instanceof SyntaxError && 'body' in err) {
    // Malformed JSON body
    appError = new AppError(
      'Invalid JSON in request body',
      400, 'INVALID_JSON', true
    );

  } else {
    // Unknown/unexpected error
    appError = new AppError(
      'An unexpected error occurred',
      500, 'INTERNAL_ERROR', false
    );
  }

  // ── Log appropriately ───────────────────────────────────

  if (appError.statusCode >= 500) {
    // Server errors — log with full stack trace and report to Sentry
    logger.error({
      requestId:  req.id,
      statusCode: appError.statusCode,
      code:       appError.code,
      error:      err instanceof Error ? err.message : String(err),
      stack:      err instanceof Error ? err.stack : undefined,
      url:        req.originalUrl,
      method:     req.method,
      userId:     (req as any).user?.id,
    }, 'Server error');

    Sentry.captureException(err);

  } else if (appError.statusCode >= 400) {
    // Client errors — log at warn level, no stack trace needed
    logger.warn({
      requestId:  req.id,
      statusCode: appError.statusCode,
      code:       appError.code,
      message:    appError.message,
      url:        req.originalUrl,
      method:     req.method,
    }, 'Client error');
  }

  // ── Send response ───────────────────────────────────────

  const response: ErrorResponse = {
    error: {
      message:   appError.message,
      code:      appError.code,
      requestId: (req as any).id || 'unknown',
    },
  };

  // Include details on client errors — helps developers debug
  // Never include details on 500s — they may expose internals
  if (appError.statusCode < 500 && appError.details) {
    response.error.details = appError.details;
  }

  res.status(appError.statusCode).json(response);
}

Mount it last in your Express app:

// src/app.ts
app.use('/api', router);
app.use(Sentry.Handlers.errorHandler());
app.use(errorHandler);

Step 3 — Throwing Errors in Business Logic

With the hierarchy in place, your route handlers and services become clean:

// src/services/orderService.ts
import { NotFoundError, AuthorizationError, UnprocessableError } from '../lib/errors';

export async function getOrder(orderId: string, userId: string, tenantId: string) {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1 AND tenant_id = $2',
    [orderId, tenantId]
  );

  if (!order.rows[0]) {
    throw new NotFoundError('Order', orderId);
  }

  if (order.rows[0].user_id !== userId) {
    throw new AuthorizationError('You do not have access to this order');
  }

  return order.rows[0];
}

export async function cancelOrder(orderId: string, userId: string, tenantId: string) {
  const order = await getOrder(orderId, userId, tenantId);

  if (order.status === 'shipped') {
    throw new UnprocessableError(
      'Orders that have been shipped cannot be cancelled',
      { currentStatus: order.status, orderId }
    );
  }

  if (order.status === 'cancelled') {
    throw new UnprocessableError('Order is already cancelled');
  }

  return db.query(
    'UPDATE orders SET status = $1 WHERE id = $2 RETURNING *',
    ['cancelled', orderId]
  );
}

Route handlers stay thin — no try-catch, no status code management:

// src/routes/orders.ts
router.get('/orders/:id', authenticate, async (req, res, next) => {
  try {
    const order = await getOrder(req.params.id, req.user.id, req.tenant.id);
    res.json(order);
  } catch (err) {
    next(err);   // Passes to errorHandler
  }
});

router.delete('/orders/:id', authenticate, async (req, res, next) => {
  try {
    await cancelOrder(req.params.id, req.user.id, req.tenant.id);
    res.status(204).send();
  } catch (err) {
    next(err);
  }
});

Or use an async wrapper to remove the try-catch boilerplate entirely:

// src/lib/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;

export function asyncHandler(fn: AsyncHandler) {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}
// Now route handlers need no try-catch at all
router.get('/orders/:id', authenticate, asyncHandler(async (req, res) => {
  const order = await getOrder(req.params.id, req.user.id, req.tenant.id);
  res.json(order);
}));

router.delete('/orders/:id', authenticate, asyncHandler(async (req, res) => {
  await cancelOrder(req.params.id, req.user.id, req.tenant.id);
  res.status(204).send();
}));

Step 4 — Process-Level Error Handling

Unhandled promise rejections and uncaught exceptions still need handling at the process level. The right behavior is to log, report to Sentry, and exit gracefully — not silently swallow the error.

// src/server.ts
process.on('unhandledRejection', (reason: unknown) => {
  logger.error({
    error: reason instanceof Error ? reason.message : String(reason),
    stack: reason instanceof Error ? reason.stack  : undefined,
  }, 'Unhandled promise rejection');

  Sentry.captureException(reason);

  // Exit and let the process manager (Docker, PM2) restart
  gracefulShutdown('unhandledRejection');
});

process.on('uncaughtException', (err: Error) => {
  logger.error({
    error: err.message,
    stack: err.stack,
  }, 'Uncaught exception');

  Sentry.captureException(err);

  // Uncaught exceptions leave the process in an unknown state
  // Always exit — do not try to recover
  gracefulShutdown('uncaughtException');
});

Step 5 — Error Boundaries for External Calls

Calls to external services (payment processors, email providers, third-party APIs) should be wrapped so their failures produce meaningful errors, not cryptic timeouts or untyped exceptions.

// src/lib/externalCall.ts
import { ServiceUnavailableError, AppError } from './errors';

interface ExternalCallOptions {
  serviceName: string;
  timeoutMs?:  number;
}

export async function externalCall<T>(
  fn:      () => Promise<T>,
  options: ExternalCallOptions
): Promise<T> {
  const { serviceName, timeoutMs = 10_000 } = options;

  const timeoutPromise = new Promise<never>((_, reject) =>
    setTimeout(
      () => reject(new ServiceUnavailableError(serviceName)),
      timeoutMs
    )
  );

  try {
    return await Promise.race([fn(), timeoutPromise]);
  } catch (err) {
    if (isAppError(err)) throw err;   // Already classified — pass through

    // Classify network and provider errors
    const message = err instanceof Error ? err.message : String(err);

    if (message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) {
      throw new ServiceUnavailableError(serviceName);
    }

    // Unknown external error — log and wrap
    logger.error({ serviceName, error: message }, 'External service error');
    throw new AppError(
      `${serviceName} returned an unexpected error`,
      502, 'EXTERNAL_SERVICE_ERROR', true
    );
  }
}

Usage:

const charge = await externalCall(
  () => stripe.paymentIntents.create({ amount, currency: 'usd' }),
  { serviceName: 'Stripe', timeoutMs: 15_000 }
);

What Consistent Error Handling Gets You

Predictable client behavior. Every error has the same shape. Clients parse error.code to decide what to show — no more guessing whether the response is { message } or { error } or { errors: [] }.

Faster debugging. Every error includes a requestId. Support tickets that include the request ID give you a direct path from complaint to log entry to root cause.

Safe 500s. Internal errors never leak stack traces or database details to clients. The details stay in your logs; clients get a safe, uninformative message.

Clear operational vs programmer errors. Operational errors (validation failures, not found, rate limits) are expected and handled. Programmer errors (uncaught exceptions, unhandled rejections) exit the process so the process manager can restart cleanly, rather than letting a corrupted process limp along.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design