{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"error-handling-architecture-in-node-js-stop-letting-exceptions-run-your-app-cs2pm","url":"https://zyvop.com/error-handling-architecture-in-node-js-stop-letting-exceptions-run-your-app-cs2pm","title":"Error Handling Architecture in Node.js: Stop Letting Exceptions Run Your App","subtitle":"A typed error hierarchy, one central handler, and the asyncHandler wrapper that eliminates try-catch from every route","tldr":"Most Node.js apps handle errors inconsistently — stack traces leaking, silent crashes, no standard shape. This guide covers a typed error hierarchy, one middleware that normalizes Zod, Postgres, and unknown errors into a consistent response, an asyncHandler that kills try-catch boilerplate, and process-level handling that exits cleanly.","keywords":["asyncHandler Express","Node.js error handling","AppError Node.js","TypeScript error classes","Zod error handling Express","Express error middleware","error handling architecture 2026","unhandledRejection Node.js"],"entities":["ZyVOP","asyncHandler Express","Node.js error handling","AppError Node.js","TypeScript error classes","Zod error handling Express","Express error middleware","error handling architecture 2026","unhandledRejection Node.js"],"keyTakeaways":["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."],"headings":["Step 1 — A Typed Error Hierarchy","Step 2 — The Central Error Handler","Step 3 — Throwing Errors in Business Logic","Step 4 — Process-Level Error Handling","Step 5 — Error Boundaries for External Calls","What Consistent Error Handling Gets You"],"outboundLinks":[],"contentText":"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) &amp;&amp; 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 &amp;&amp; '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 &gt;= 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 &gt;= 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 &lt; 500 &amp;&amp; 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) =&gt; { 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) =&gt; { 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) =&gt; Promise&lt;unknown&gt;; export function asyncHandler(fn: AsyncHandler) { return (req: Request, res: Response, next: NextFunction) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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&lt;T&gt;( fn: () =&gt; Promise&lt;T&gt;, options: ExternalCallOptions ): Promise&lt;T&gt; { const { serviceName, timeoutMs = 10_000 } = options; const timeoutPromise = new Promise&lt;never&gt;((_, reject) =&gt; setTimeout( () =&gt; 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( () =&gt; 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.","contentHash":"sha256:08f1959b144e7335455c3f085838328bed9705ffc79ca8bcc8157b60aa48ca1e","authorName":"ZyVOP","authorUrl":"https://zyvop.com/author/zyvop","authorSameAs":["https://zyvop.com","https://github.com/zyvop","https://x.com/zyvop1"],"category":null,"tags":["asyncHandler Express","Node.js error handling","AppError Node.js","TypeScript error classes","Zod error handling Express","Express error middleware","error handling architecture 2026","unhandledRejection Node.js"],"audience":"Senior software engineers, systems architects, and technical leads working with asyncHandler Express","tone":"Instructional, practical, code-first","readingTimeMinutes":7,"wordCount":1582,"faqs":null,"primaryTopic":"asyncHandler Express","publishedAt":"2026-05-26T11:17:41.262Z","updatedAt":"2026-08-18T12:35:00.395Z","canonicalUrl":"https://zyvop.com/error-handling-architecture-in-node-js-stop-letting-exceptions-run-your-app-cs2pm"}