Back to Blog
Node.jsError HandlingBest PracticesJavaScript

Error Handling in Node.js Applications

Implement robust error handling in Node.js. From async errors to custom error classes to centralized handling patterns.

B
Bootspring Team
Engineering
October 11, 2021
8 min read

Proper error handling prevents crashes and improves debugging. Here's how to handle errors effectively in Node.js.

Custom Error Classes#

1// Base application error 2class AppError extends Error { 3 public readonly statusCode: number; 4 public readonly isOperational: boolean; 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.statusCode = statusCode; 15 this.code = code; 16 this.isOperational = isOperational; 17 18 Error.captureStackTrace(this, this.constructor); 19 Object.setPrototypeOf(this, new.target.prototype); 20 } 21} 22 23// Specific error types 24class ValidationError extends AppError { 25 public readonly errors: Record<string, string>; 26 27 constructor(errors: Record<string, string>) { 28 super('Validation failed', 400, 'VALIDATION_ERROR'); 29 this.errors = errors; 30 } 31} 32 33class NotFoundError extends AppError { 34 constructor(resource: string) { 35 super(`${resource} not found`, 404, 'NOT_FOUND'); 36 } 37} 38 39class UnauthorizedError extends AppError { 40 constructor(message: string = 'Unauthorized') { 41 super(message, 401, 'UNAUTHORIZED'); 42 } 43} 44 45class ForbiddenError extends AppError { 46 constructor(message: string = 'Forbidden') { 47 super(message, 403, 'FORBIDDEN'); 48 } 49} 50 51class ConflictError extends AppError { 52 constructor(message: string) { 53 super(message, 409, 'CONFLICT'); 54 } 55} 56 57class RateLimitError extends AppError { 58 public readonly retryAfter: number; 59 60 constructor(retryAfter: number = 60) { 61 super('Too many requests', 429, 'RATE_LIMIT'); 62 this.retryAfter = retryAfter; 63 } 64}

Async Error Handling#

1// Async wrapper for Express routes 2const asyncHandler = ( 3 fn: (req: Request, res: Response, next: NextFunction) => Promise<any> 4) => { 5 return (req: Request, res: Response, next: NextFunction) => { 6 Promise.resolve(fn(req, res, next)).catch(next); 7 }; 8}; 9 10// Usage 11app.get('/users/:id', asyncHandler(async (req, res) => { 12 const user = await userService.findById(req.params.id); 13 14 if (!user) { 15 throw new NotFoundError('User'); 16 } 17 18 res.json(user); 19})); 20 21// Alternative: express-async-errors 22import 'express-async-errors'; 23 24// Now async errors are caught automatically 25app.get('/users/:id', async (req, res) => { 26 const user = await userService.findById(req.params.id); 27 28 if (!user) { 29 throw new NotFoundError('User'); 30 } 31 32 res.json(user); 33});

Promise Error Handling#

1// Proper promise chain error handling 2async function processOrder(orderId: string) { 3 try { 4 const order = await getOrder(orderId); 5 const payment = await processPayment(order); 6 const shipment = await createShipment(order, payment); 7 8 return { order, payment, shipment }; 9 } catch (error) { 10 if (error instanceof PaymentError) { 11 await refundOrder(orderId); 12 throw new AppError('Payment failed', 402, 'PAYMENT_FAILED'); 13 } 14 15 if (error instanceof ShipmentError) { 16 await cancelPayment(orderId); 17 throw new AppError('Shipment creation failed', 500, 'SHIPMENT_FAILED'); 18 } 19 20 throw error; 21 } 22} 23 24// Handle multiple promises 25async function fetchAllData() { 26 const [users, posts, comments] = await Promise.all([ 27 fetchUsers().catch(() => []), // Return empty on failure 28 fetchPosts().catch(() => []), 29 fetchComments().catch(() => []), 30 ]); 31 32 return { users, posts, comments }; 33} 34 35// With error details 36async function fetchWithErrors() { 37 const results = await Promise.allSettled([ 38 fetchUsers(), 39 fetchPosts(), 40 fetchComments(), 41 ]); 42 43 const errors = results 44 .filter((r): r is PromiseRejectedResult => r.status === 'rejected') 45 .map(r => r.reason); 46 47 if (errors.length > 0) { 48 console.error('Some requests failed:', errors); 49 } 50 51 const data = results 52 .filter((r): r is PromiseFulfilledResult<any> => r.status === 'fulfilled') 53 .map(r => r.value); 54 55 return data; 56}

Centralized Error Handler#

1// errorHandler.ts 2interface ErrorResponse { 3 success: false; 4 error: { 5 code: string; 6 message: string; 7 details?: any; 8 }; 9} 10 11function errorHandler( 12 err: Error, 13 req: Request, 14 res: Response, 15 next: NextFunction 16) { 17 // Log error 18 console.error('Error:', { 19 message: err.message, 20 stack: err.stack, 21 path: req.path, 22 method: req.method, 23 body: req.body, 24 user: req.user?.id, 25 }); 26 27 // Handle known errors 28 if (err instanceof AppError) { 29 const response: ErrorResponse = { 30 success: false, 31 error: { 32 code: err.code, 33 message: err.message, 34 }, 35 }; 36 37 if (err instanceof ValidationError) { 38 response.error.details = err.errors; 39 } 40 41 return res.status(err.statusCode).json(response); 42 } 43 44 // Handle Prisma errors 45 if (err.name === 'PrismaClientKnownRequestError') { 46 const prismaError = err as any; 47 48 if (prismaError.code === 'P2002') { 49 return res.status(409).json({ 50 success: false, 51 error: { 52 code: 'DUPLICATE_ENTRY', 53 message: 'A record with this value already exists', 54 details: prismaError.meta?.target, 55 }, 56 }); 57 } 58 59 if (prismaError.code === 'P2025') { 60 return res.status(404).json({ 61 success: false, 62 error: { 63 code: 'NOT_FOUND', 64 message: 'Record not found', 65 }, 66 }); 67 } 68 } 69 70 // Handle JWT errors 71 if (err.name === 'JsonWebTokenError') { 72 return res.status(401).json({ 73 success: false, 74 error: { 75 code: 'INVALID_TOKEN', 76 message: 'Invalid authentication token', 77 }, 78 }); 79 } 80 81 if (err.name === 'TokenExpiredError') { 82 return res.status(401).json({ 83 success: false, 84 error: { 85 code: 'TOKEN_EXPIRED', 86 message: 'Authentication token has expired', 87 }, 88 }); 89 } 90 91 // Handle validation errors (Joi, Zod, etc.) 92 if (err.name === 'ZodError') { 93 return res.status(400).json({ 94 success: false, 95 error: { 96 code: 'VALIDATION_ERROR', 97 message: 'Invalid request data', 98 details: (err as any).errors, 99 }, 100 }); 101 } 102 103 // Default error 104 const statusCode = (err as any).statusCode || 500; 105 const message = process.env.NODE_ENV === 'production' 106 ? 'An unexpected error occurred' 107 : err.message; 108 109 res.status(statusCode).json({ 110 success: false, 111 error: { 112 code: 'INTERNAL_ERROR', 113 message, 114 }, 115 }); 116} 117 118// Register error handler last 119app.use(errorHandler);

Unhandled Errors#

1// Global error handlers 2process.on('uncaughtException', (error: Error) => { 3 console.error('Uncaught Exception:', error); 4 5 // Log to monitoring service 6 logErrorToService(error); 7 8 // Graceful shutdown 9 process.exit(1); 10}); 11 12process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { 13 console.error('Unhandled Rejection at:', promise, 'reason:', reason); 14 15 // Log to monitoring service 16 logErrorToService(reason); 17 18 // Optionally exit 19 // process.exit(1); 20}); 21 22// Graceful shutdown 23process.on('SIGTERM', async () => { 24 console.log('SIGTERM received. Shutting down gracefully...'); 25 26 // Close database connections 27 await prisma.$disconnect(); 28 29 // Close server 30 server.close(() => { 31 console.log('Server closed'); 32 process.exit(0); 33 }); 34 35 // Force close after timeout 36 setTimeout(() => { 37 console.error('Forced shutdown'); 38 process.exit(1); 39 }, 10000); 40});

Service Layer Error Handling#

1// userService.ts 2class UserService { 3 async findById(id: string): Promise<User> { 4 const user = await prisma.user.findUnique({ 5 where: { id }, 6 }); 7 8 if (!user) { 9 throw new NotFoundError('User'); 10 } 11 12 return user; 13 } 14 15 async create(data: CreateUserDTO): Promise<User> { 16 // Validate 17 const existingUser = await prisma.user.findUnique({ 18 where: { email: data.email }, 19 }); 20 21 if (existingUser) { 22 throw new ConflictError('Email already registered'); 23 } 24 25 // Validate password strength 26 if (!isStrongPassword(data.password)) { 27 throw new ValidationError({ 28 password: 'Password must be at least 8 characters with a number and special character', 29 }); 30 } 31 32 try { 33 const hashedPassword = await hashPassword(data.password); 34 35 return await prisma.user.create({ 36 data: { 37 ...data, 38 password: hashedPassword, 39 }, 40 }); 41 } catch (error) { 42 // Handle specific database errors 43 throw new AppError('Failed to create user', 500, 'USER_CREATE_FAILED'); 44 } 45 } 46 47 async update(id: string, data: UpdateUserDTO): Promise<User> { 48 await this.findById(id); // Throws if not found 49 50 try { 51 return await prisma.user.update({ 52 where: { id }, 53 data, 54 }); 55 } catch (error) { 56 if (error instanceof NotFoundError) { 57 throw error; 58 } 59 throw new AppError('Failed to update user', 500, 'USER_UPDATE_FAILED'); 60 } 61 } 62}

Try-Catch Patterns#

1// Result type pattern 2type Result<T, E = Error> = 3 | { success: true; data: T } 4 | { success: false; error: E }; 5 6async function safeAsync<T>( 7 promise: Promise<T> 8): Promise<Result<T>> { 9 try { 10 const data = await promise; 11 return { success: true, data }; 12 } catch (error) { 13 return { success: false, error: error as Error }; 14 } 15} 16 17// Usage 18async function processUser(id: string) { 19 const result = await safeAsync(userService.findById(id)); 20 21 if (!result.success) { 22 console.error('Failed to find user:', result.error); 23 return null; 24 } 25 26 return result.data; 27} 28 29// Multiple operations with cleanup 30async function createUserWithProfile(data: CreateUserData) { 31 let user: User | null = null; 32 33 try { 34 user = await userService.create(data.user); 35 const profile = await profileService.create(user.id, data.profile); 36 37 return { user, profile }; 38 } catch (error) { 39 // Cleanup on failure 40 if (user) { 41 await userService.delete(user.id).catch(() => {}); 42 } 43 throw error; 44 } 45}

Logging Errors#

1// logger.ts 2import winston from 'winston'; 3 4const logger = winston.createLogger({ 5 level: process.env.LOG_LEVEL || 'info', 6 format: winston.format.combine( 7 winston.format.timestamp(), 8 winston.format.errors({ stack: true }), 9 winston.format.json() 10 ), 11 transports: [ 12 new winston.transports.File({ filename: 'error.log', level: 'error' }), 13 new winston.transports.File({ filename: 'combined.log' }), 14 ], 15}); 16 17if (process.env.NODE_ENV !== 'production') { 18 logger.add(new winston.transports.Console({ 19 format: winston.format.simple(), 20 })); 21} 22 23// Error logging utility 24function logError(error: Error, context?: Record<string, any>) { 25 const errorInfo = { 26 name: error.name, 27 message: error.message, 28 stack: error.stack, 29 code: (error as AppError).code, 30 statusCode: (error as AppError).statusCode, 31 ...context, 32 }; 33 34 if (error instanceof AppError && error.isOperational) { 35 logger.warn('Operational error', errorInfo); 36 } else { 37 logger.error('System error', errorInfo); 38 } 39} 40 41// In error handler 42function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) { 43 logError(err, { 44 path: req.path, 45 method: req.method, 46 userId: req.user?.id, 47 requestId: req.headers['x-request-id'], 48 }); 49 50 // ... rest of error handling 51}

Testing Error Handling#

1describe('UserService', () => { 2 describe('findById', () => { 3 it('throws NotFoundError when user does not exist', async () => { 4 await expect(userService.findById('non-existent')) 5 .rejects 6 .toThrow(NotFoundError); 7 }); 8 9 it('returns user when found', async () => { 10 const user = await createTestUser(); 11 const found = await userService.findById(user.id); 12 expect(found.id).toBe(user.id); 13 }); 14 }); 15 16 describe('create', () => { 17 it('throws ConflictError for duplicate email', async () => { 18 await createTestUser({ email: 'test@example.com' }); 19 20 await expect( 21 userService.create({ email: 'test@example.com', password: 'Test123!' }) 22 ) 23 .rejects 24 .toThrow(ConflictError); 25 }); 26 27 it('throws ValidationError for weak password', async () => { 28 await expect( 29 userService.create({ email: 'new@example.com', password: 'weak' }) 30 ) 31 .rejects 32 .toThrow(ValidationError); 33 }); 34 }); 35}); 36 37// Test error handler 38describe('Error Handler', () => { 39 it('returns 404 for NotFoundError', async () => { 40 const response = await request(app) 41 .get('/users/non-existent') 42 .expect(404); 43 44 expect(response.body.error.code).toBe('NOT_FOUND'); 45 }); 46 47 it('returns 400 for validation errors', async () => { 48 const response = await request(app) 49 .post('/users') 50 .send({ email: 'invalid' }) 51 .expect(400); 52 53 expect(response.body.error.code).toBe('VALIDATION_ERROR'); 54 }); 55});

Best Practices#

Error Design: ✓ Use custom error classes ✓ Include error codes ✓ Separate operational from programmer errors ✓ Provide helpful messages Handling: ✓ Always use try-catch with async/await ✓ Handle errors at appropriate level ✓ Clean up resources on failure ✓ Log errors with context Response: ✓ Don't expose internal errors in production ✓ Use consistent error format ✓ Include request ID for debugging ✓ Return appropriate status codes Monitoring: ✓ Log all errors ✓ Track error rates ✓ Alert on critical errors ✓ Review error logs regularly

Conclusion#

Robust error handling improves application reliability and debugging. Use custom error classes, centralized error handlers, and proper logging. Always catch async errors, handle cleanup on failure, and test error paths thoroughly.

Share this article

Help spread the word about Bootspring