Back to Blog
Error HandlingNode.jsTypeScriptBest Practices

Error Handling Patterns: Building Resilient Node.js Applications

Implement robust error handling in Node.js. Learn error types, async patterns, and strategies for graceful failure recovery.

B
Bootspring Team
Engineering
February 26, 2026
7 min read

Proper error handling distinguishes production-ready applications from prototypes. This guide covers error handling patterns for Node.js and TypeScript applications.

Custom Error Classes#

Error Hierarchy#

1// Base application error 2export class AppError extends Error { 3 public readonly isOperational: boolean; 4 public readonly statusCode: number; 5 public readonly code: string; 6 7 constructor( 8 message: string, 9 statusCode: number = 500, 10 code: string = 'INTERNAL_ERROR', 11 isOperational: boolean = true 12 ) { 13 super(message); 14 this.name = this.constructor.name; 15 this.statusCode = statusCode; 16 this.code = code; 17 this.isOperational = isOperational; 18 19 Error.captureStackTrace(this, this.constructor); 20 } 21} 22 23// Specific error types 24export class ValidationError extends AppError { 25 public readonly details: Array<{ field: string; message: string }>; 26 27 constructor(message: string, details: Array<{ field: string; message: string }> = []) { 28 super(message, 400, 'VALIDATION_ERROR'); 29 this.details = details; 30 } 31} 32 33export class NotFoundError extends AppError { 34 constructor(resource: string, id?: string) { 35 const message = id 36 ? `${resource} with ID ${id} not found` 37 : `${resource} not found`; 38 super(message, 404, 'NOT_FOUND'); 39 } 40} 41 42export class UnauthorizedError extends AppError { 43 constructor(message: string = 'Authentication required') { 44 super(message, 401, 'UNAUTHORIZED'); 45 } 46} 47 48export class ForbiddenError extends AppError { 49 constructor(message: string = 'Access denied') { 50 super(message, 403, 'FORBIDDEN'); 51 } 52} 53 54export class ConflictError extends AppError { 55 constructor(message: string) { 56 super(message, 409, 'CONFLICT'); 57 } 58} 59 60export class RateLimitError extends AppError { 61 public readonly retryAfter: number; 62 63 constructor(retryAfter: number) { 64 super('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED'); 65 this.retryAfter = retryAfter; 66 } 67}

Result Type Pattern#

Type-Safe Error Handling#

1type Result<T, E = Error> = 2 | { success: true; data: T } 3 | { success: false; error: E }; 4 5function ok<T>(data: T): Result<T, never> { 6 return { success: true, data }; 7} 8 9function err<E>(error: E): Result<never, E> { 10 return { success: false, error }; 11} 12 13// Usage in services 14async function getUserById(id: string): Promise<Result<User, NotFoundError>> { 15 const user = await db.users.findById(id); 16 17 if (!user) { 18 return err(new NotFoundError('User', id)); 19 } 20 21 return ok(user); 22} 23 24// Usage in controllers 25async function getUser(req: Request, res: Response) { 26 const result = await getUserById(req.params.id); 27 28 if (!result.success) { 29 return res.status(result.error.statusCode).json({ 30 code: result.error.code, 31 message: result.error.message, 32 }); 33 } 34 35 return res.json(result.data); 36}

With neverthrow Library#

1import { ok, err, Result, ResultAsync } from 'neverthrow'; 2 3class UserService { 4 findById(id: string): ResultAsync<User, NotFoundError> { 5 return ResultAsync.fromPromise( 6 db.users.findById(id), 7 () => new NotFoundError('User', id) 8 ).andThen(user => 9 user ? ok(user) : err(new NotFoundError('User', id)) 10 ); 11 } 12 13 create(data: CreateUserData): ResultAsync<User, ValidationError | ConflictError> { 14 return this.validateUser(data) 15 .asyncAndThen(validated => 16 this.checkEmailUnique(validated.email) 17 .andThen(() => this.saveUser(validated)) 18 ); 19 } 20} 21 22// Chaining results 23const result = await userService.findById('123') 24 .andThen(user => orderService.findByUser(user.id)) 25 .map(orders => ({ orders, count: orders.length })); 26 27result.match( 28 data => res.json(data), 29 error => res.status(error.statusCode).json({ error: error.message }) 30);

Express Error Handling#

Async Handler Wrapper#

1type AsyncHandler = ( 2 req: Request, 3 res: Response, 4 next: NextFunction 5) => Promise<any>; 6 7function asyncHandler(fn: AsyncHandler): RequestHandler { 8 return (req, res, next) => { 9 Promise.resolve(fn(req, res, next)).catch(next); 10 }; 11} 12 13// Usage 14app.get('/users/:id', asyncHandler(async (req, res) => { 15 const user = await userService.findById(req.params.id); 16 17 if (!user) { 18 throw new NotFoundError('User', req.params.id); 19 } 20 21 res.json(user); 22}));

Global Error Handler#

1interface ErrorResponse { 2 code: string; 3 message: string; 4 details?: unknown; 5 stack?: string; 6} 7 8function errorHandler( 9 err: Error, 10 req: Request, 11 res: Response, 12 next: NextFunction 13): void { 14 // Log error 15 logger.error({ 16 error: { 17 name: err.name, 18 message: err.message, 19 stack: err.stack, 20 }, 21 request: { 22 method: req.method, 23 path: req.path, 24 query: req.query, 25 body: sanitize(req.body), 26 headers: { 27 'user-agent': req.headers['user-agent'], 28 'x-request-id': req.headers['x-request-id'], 29 }, 30 }, 31 }); 32 33 // Handle operational errors 34 if (err instanceof AppError) { 35 const response: ErrorResponse = { 36 code: err.code, 37 message: err.message, 38 }; 39 40 if (err instanceof ValidationError) { 41 response.details = err.details; 42 } 43 44 if (err instanceof RateLimitError) { 45 res.set('Retry-After', err.retryAfter.toString()); 46 } 47 48 res.status(err.statusCode).json(response); 49 return; 50 } 51 52 // Handle Prisma errors 53 if (err.name === 'PrismaClientKnownRequestError') { 54 const prismaError = err as PrismaClientKnownRequestError; 55 56 if (prismaError.code === 'P2002') { 57 res.status(409).json({ 58 code: 'CONFLICT', 59 message: 'A record with this value already exists', 60 }); 61 return; 62 } 63 64 if (prismaError.code === 'P2025') { 65 res.status(404).json({ 66 code: 'NOT_FOUND', 67 message: 'Record not found', 68 }); 69 return; 70 } 71 } 72 73 // Handle validation errors (Zod, Joi, etc.) 74 if (err.name === 'ZodError') { 75 res.status(400).json({ 76 code: 'VALIDATION_ERROR', 77 message: 'Invalid request data', 78 details: (err as ZodError).errors, 79 }); 80 return; 81 } 82 83 // Unknown errors 84 const response: ErrorResponse = { 85 code: 'INTERNAL_ERROR', 86 message: process.env.NODE_ENV === 'production' 87 ? 'An unexpected error occurred' 88 : err.message, 89 }; 90 91 if (process.env.NODE_ENV !== 'production') { 92 response.stack = err.stack; 93 } 94 95 res.status(500).json(response); 96} 97 98// Apply middleware 99app.use(errorHandler);

Retry Patterns#

Exponential Backoff#

1interface RetryOptions { 2 maxAttempts: number; 3 baseDelayMs: number; 4 maxDelayMs: number; 5 retryOn?: (error: Error) => boolean; 6} 7 8async function withRetry<T>( 9 fn: () => Promise<T>, 10 options: RetryOptions 11): Promise<T> { 12 const { maxAttempts, baseDelayMs, maxDelayMs, retryOn } = options; 13 14 let lastError: Error; 15 16 for (let attempt = 1; attempt <= maxAttempts; attempt++) { 17 try { 18 return await fn(); 19 } catch (error) { 20 lastError = error as Error; 21 22 // Check if we should retry 23 if (retryOn && !retryOn(lastError)) { 24 throw lastError; 25 } 26 27 if (attempt === maxAttempts) { 28 throw lastError; 29 } 30 31 // Calculate delay with exponential backoff and jitter 32 const delay = Math.min( 33 baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 100, 34 maxDelayMs 35 ); 36 37 logger.warn({ 38 message: 'Retrying operation', 39 attempt, 40 maxAttempts, 41 delay, 42 error: lastError.message, 43 }); 44 45 await sleep(delay); 46 } 47 } 48 49 throw lastError!; 50} 51 52// Usage 53const data = await withRetry( 54 () => fetchFromExternalAPI(url), 55 { 56 maxAttempts: 3, 57 baseDelayMs: 1000, 58 maxDelayMs: 10000, 59 retryOn: (error) => { 60 // Retry on network errors and 5xx responses 61 return error.name === 'FetchError' || 62 (error instanceof APIError && error.statusCode >= 500); 63 }, 64 } 65);

Circuit Breaker#

1enum CircuitState { 2 CLOSED = 'CLOSED', 3 OPEN = 'OPEN', 4 HALF_OPEN = 'HALF_OPEN', 5} 6 7class CircuitBreaker { 8 private state: CircuitState = CircuitState.CLOSED; 9 private failureCount: number = 0; 10 private lastFailureTime: number = 0; 11 private successCount: number = 0; 12 13 constructor( 14 private options: { 15 failureThreshold: number; 16 resetTimeout: number; 17 halfOpenRequests: number; 18 } 19 ) {} 20 21 async execute<T>(fn: () => Promise<T>): Promise<T> { 22 if (this.state === CircuitState.OPEN) { 23 if (Date.now() - this.lastFailureTime >= this.options.resetTimeout) { 24 this.state = CircuitState.HALF_OPEN; 25 this.successCount = 0; 26 } else { 27 throw new Error('Circuit breaker is OPEN'); 28 } 29 } 30 31 try { 32 const result = await fn(); 33 this.onSuccess(); 34 return result; 35 } catch (error) { 36 this.onFailure(); 37 throw error; 38 } 39 } 40 41 private onSuccess(): void { 42 if (this.state === CircuitState.HALF_OPEN) { 43 this.successCount++; 44 if (this.successCount >= this.options.halfOpenRequests) { 45 this.state = CircuitState.CLOSED; 46 this.failureCount = 0; 47 } 48 } else { 49 this.failureCount = 0; 50 } 51 } 52 53 private onFailure(): void { 54 this.failureCount++; 55 this.lastFailureTime = Date.now(); 56 57 if (this.failureCount >= this.options.failureThreshold) { 58 this.state = CircuitState.OPEN; 59 } 60 } 61}

Process-Level Error Handling#

1// Handle uncaught exceptions 2process.on('uncaughtException', (error: Error) => { 3 logger.fatal({ error }, 'Uncaught exception'); 4 5 // Graceful shutdown 6 gracefulShutdown(1); 7}); 8 9// Handle unhandled promise rejections 10process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => { 11 logger.error({ reason, promise }, 'Unhandled rejection'); 12 13 // In production, you might want to crash 14 // throw reason; 15}); 16 17// Graceful shutdown 18async function gracefulShutdown(exitCode: number = 0): Promise<void> { 19 logger.info('Starting graceful shutdown...'); 20 21 // Stop accepting new connections 22 server.close(); 23 24 // Close database connections 25 await db.$disconnect(); 26 27 // Close Redis connections 28 await redis.quit(); 29 30 logger.info('Shutdown complete'); 31 process.exit(exitCode); 32} 33 34process.on('SIGTERM', () => gracefulShutdown(0)); 35process.on('SIGINT', () => gracefulShutdown(0));

Best Practices#

  1. Use typed errors: Create specific error classes for different scenarios
  2. Don't swallow errors: Always handle or propagate errors
  3. Log with context: Include request IDs, user IDs, and relevant data
  4. Fail fast: Validate inputs early and throw immediately
  5. Be consistent: Use the same error format throughout your API
  6. Provide actionable messages: Help users understand what went wrong

Conclusion#

Good error handling makes applications maintainable and debuggable. Use custom error classes for type safety, implement proper async handling, and ensure graceful degradation. Remember to handle both expected errors (validation, not found) and unexpected ones (bugs, infrastructure failures).

Share this article

Help spread the word about Bootspring