Back to Blog
Error HandlingBest PracticesDebuggingReliability

Error Handling Best Practices for Robust Applications

Master error handling patterns that make applications resilient, debuggable, and user-friendly. From try-catch to global handlers.

B
Bootspring Team
Engineering
November 8, 2025
7 min read

Error handling separates amateur code from production-ready applications. Done poorly, errors crash systems and frustrate users. Done well, errors are caught, logged, reported, and recovered from gracefully. AI can help you implement robust error handling patterns.

The Error Handling Mindset#

Good error handling requires thinking about failure modes:

  1. What can go wrong? Network failures, invalid input, resource exhaustion
  2. How will we know? Logging, monitoring, alerting
  3. How will we respond? Retry, fallback, fail gracefully
  4. How will users be informed? Clear, actionable messages

Custom Error Classes#

Structured Errors#

1class AppError extends Error { 2 constructor( 3 message: string, 4 public readonly code: string, 5 public readonly statusCode: number = 500, 6 public readonly isOperational: boolean = true, 7 public readonly context?: Record<string, unknown> 8 ) { 9 super(message); 10 this.name = this.constructor.name; 11 Error.captureStackTrace(this, this.constructor); 12 } 13} 14 15class ValidationError extends AppError { 16 constructor(message: string, public readonly fields: Record<string, string>) { 17 super(message, 'VALIDATION_ERROR', 400, true, { fields }); 18 } 19} 20 21class NotFoundError extends AppError { 22 constructor(resource: string, id: string) { 23 super(`${resource} not found: ${id}`, 'NOT_FOUND', 404, true, { resource, id }); 24 } 25} 26 27class DatabaseError extends AppError { 28 constructor(message: string, public readonly originalError: Error) { 29 super(message, 'DATABASE_ERROR', 500, false, { 30 originalMessage: originalError.message 31 }); 32 } 33}

Error Factory Pattern#

1const Errors = { 2 validation: (fields: Record<string, string>) => 3 new ValidationError('Validation failed', fields), 4 5 notFound: (resource: string, id: string) => 6 new NotFoundError(resource, id), 7 8 unauthorized: (reason: string) => 9 new AppError(reason, 'UNAUTHORIZED', 401, true), 10 11 forbidden: (action: string) => 12 new AppError(`Not allowed: ${action}`, 'FORBIDDEN', 403, true), 13 14 rateLimit: (limit: number, window: string) => 15 new AppError(`Rate limit exceeded: ${limit} per ${window}`, 'RATE_LIMIT', 429, true), 16 17 internal: (message: string, context?: Record<string, unknown>) => 18 new AppError(message, 'INTERNAL_ERROR', 500, false, context) 19}; 20 21// Usage 22throw Errors.notFound('User', userId); 23throw Errors.validation({ email: 'Invalid email format' });

Error Handling Patterns#

Try-Catch Best Practices#

1// ❌ Catching everything silently 2try { 3 await riskyOperation(); 4} catch (e) { 5 // Silently swallowed 6} 7 8// ❌ Generic catch with no handling 9try { 10 await riskyOperation(); 11} catch (e) { 12 console.log(e); 13} 14 15// ✅ Specific, informative handling 16try { 17 await riskyOperation(); 18} catch (error) { 19 if (error instanceof NetworkError) { 20 logger.warn('Network error, retrying...', { error }); 21 return retry(riskyOperation); 22 } 23 24 if (error instanceof ValidationError) { 25 // User error, return appropriate response 26 throw error; 27 } 28 29 // Unexpected error, log and re-throw 30 logger.error('Unexpected error in riskyOperation', { 31 error, 32 context: { input } 33 }); 34 throw error; 35}

Result Pattern (No Exceptions)#

1type Result<T, E = Error> = 2 | { success: true; data: T } 3 | { success: false; error: E }; 4 5async function parseUserInput(input: string): Promise<Result<User, ValidationError>> { 6 try { 7 const validated = schema.parse(input); 8 return { success: true, data: validated }; 9 } catch (error) { 10 if (error instanceof z.ZodError) { 11 return { 12 success: false, 13 error: new ValidationError('Invalid input', formatZodErrors(error)) 14 }; 15 } 16 throw error; // Unexpected error, let it bubble 17 } 18} 19 20// Usage 21const result = await parseUserInput(input); 22if (!result.success) { 23 return res.status(400).json({ errors: result.error.fields }); 24} 25const user = result.data;

Error Boundaries (React)#

1class ErrorBoundary extends Component<Props, State> { 2 state = { hasError: false, error: null }; 3 4 static getDerivedStateFromError(error: Error) { 5 return { hasError: true, error }; 6 } 7 8 componentDidCatch(error: Error, errorInfo: ErrorInfo) { 9 logger.error('React error boundary caught error', { 10 error, 11 componentStack: errorInfo.componentStack 12 }); 13 14 // Report to error tracking service 15 errorTracker.captureException(error, { extra: errorInfo }); 16 } 17 18 render() { 19 if (this.state.hasError) { 20 return this.props.fallback || <ErrorFallback error={this.state.error} />; 21 } 22 return this.props.children; 23 } 24} 25 26// Usage 27<ErrorBoundary fallback={<PaymentErrorUI />}> 28 <PaymentForm /> 29</ErrorBoundary>

Global Error Handling#

Express Error Handler#

1// Error handling middleware (must be last) 2app.use((error: Error, req: Request, res: Response, next: NextFunction) => { 3 // Generate request ID for correlation 4 const requestId = req.headers['x-request-id'] || generateId(); 5 6 // Log error with context 7 const logContext = { 8 requestId, 9 method: req.method, 10 path: req.path, 11 userId: req.user?.id, 12 error: { 13 name: error.name, 14 message: error.message, 15 stack: error.stack 16 } 17 }; 18 19 if (error instanceof AppError) { 20 if (!error.isOperational) { 21 logger.error('Non-operational error', logContext); 22 } else { 23 logger.warn('Operational error', logContext); 24 } 25 26 return res.status(error.statusCode).json({ 27 error: { 28 code: error.code, 29 message: error.message, 30 ...(error instanceof ValidationError && { fields: error.fields }), 31 requestId 32 } 33 }); 34 } 35 36 // Unexpected error 37 logger.error('Unexpected error', logContext); 38 39 // Don't leak error details in production 40 res.status(500).json({ 41 error: { 42 code: 'INTERNAL_ERROR', 43 message: process.env.NODE_ENV === 'production' 44 ? 'An unexpected error occurred' 45 : error.message, 46 requestId 47 } 48 }); 49});

Unhandled Rejection Handling#

1process.on('unhandledRejection', (reason, promise) => { 2 logger.error('Unhandled Rejection', { 3 reason, 4 promise 5 }); 6 7 // In production, you might want to: 8 // 1. Report to error tracking 9 // 2. Gracefully shutdown 10 // 3. Let process manager restart 11}); 12 13process.on('uncaughtException', (error) => { 14 logger.error('Uncaught Exception', { error }); 15 16 // Must exit process after uncaught exception 17 process.exit(1); 18});

Async Error Handling#

Promise Chain Errors#

1// ❌ Missing error handling 2fetchUser(userId) 3 .then(user => fetchOrders(user.id)) 4 .then(orders => processOrders(orders)); 5 6// ✅ Proper error handling 7fetchUser(userId) 8 .then(user => fetchOrders(user.id)) 9 .then(orders => processOrders(orders)) 10 .catch(error => { 11 if (error instanceof NotFoundError) { 12 return handleNotFound(error); 13 } 14 throw error; 15 }); 16 17// ✅ Even better: async/await with try-catch 18async function getUserOrders(userId: string) { 19 try { 20 const user = await fetchUser(userId); 21 const orders = await fetchOrders(user.id); 22 return processOrders(orders); 23 } catch (error) { 24 if (error instanceof NotFoundError) { 25 return handleNotFound(error); 26 } 27 throw error; 28 } 29}

Parallel Operations#

1// ❌ Promise.all fails fast, loses other results 2const [users, orders, products] = await Promise.all([ 3 fetchUsers(), 4 fetchOrders(), 5 fetchProducts() 6]); 7 8// ✅ Promise.allSettled for independent operations 9const results = await Promise.allSettled([ 10 fetchUsers(), 11 fetchOrders(), 12 fetchProducts() 13]); 14 15const data = { 16 users: results[0].status === 'fulfilled' ? results[0].value : [], 17 orders: results[1].status === 'fulfilled' ? results[1].value : [], 18 products: results[2].status === 'fulfilled' ? results[2].value : [] 19}; 20 21// Log any failures 22results.forEach((result, index) => { 23 if (result.status === 'rejected') { 24 logger.error(`Fetch ${['users', 'orders', 'products'][index]} failed`, { 25 error: result.reason 26 }); 27 } 28});

User-Facing Error Messages#

Error Message Guidelines#

1const userMessages = { 2 VALIDATION_ERROR: 'Please check your input and try again.', 3 NOT_FOUND: 'The requested item could not be found.', 4 UNAUTHORIZED: 'Please sign in to continue.', 5 FORBIDDEN: 'You don\'t have permission to do this.', 6 RATE_LIMIT: 'Too many requests. Please wait a moment.', 7 NETWORK_ERROR: 'Connection problem. Please check your internet.', 8 INTERNAL_ERROR: 'Something went wrong. Please try again.', 9}; 10 11function getUserMessage(error: AppError): string { 12 return userMessages[error.code] || userMessages.INTERNAL_ERROR; 13}

Actionable Error UI#

1function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) { 2 return ( 3 <div className="error-container"> 4 <ErrorIcon /> 5 <h3>{getUserMessage(error)}</h3> 6 7 {error instanceof ValidationError && ( 8 <ul className="validation-errors"> 9 {Object.entries(error.fields).map(([field, message]) => ( 10 <li key={field}>{message}</li> 11 ))} 12 </ul> 13 )} 14 15 {error.code === 'NETWORK_ERROR' && ( 16 <button onClick={onRetry}>Try Again</button> 17 )} 18 19 {error.code === 'UNAUTHORIZED' && ( 20 <Link to="/login">Sign In</Link> 21 )} 22 23 <details> 24 <summary>Technical Details</summary> 25 <code>Error Code: {error.code}</code> 26 </details> 27 </div> 28 ); 29}

Error Monitoring and Alerting#

Error Tracking Integration#

1import * as Sentry from '@sentry/node'; 2 3Sentry.init({ 4 dsn: process.env.SENTRY_DSN, 5 environment: process.env.NODE_ENV, 6 beforeSend(event, hint) { 7 // Filter out operational errors 8 const error = hint.originalException as AppError; 9 if (error?.isOperational) { 10 return null; // Don't send to Sentry 11 } 12 return event; 13 } 14}); 15 16// Add context to errors 17app.use((req, res, next) => { 18 Sentry.setUser({ id: req.user?.id, email: req.user?.email }); 19 Sentry.setTag('request_id', req.headers['x-request-id']); 20 next(); 21});

Conclusion#

Error handling is about anticipating failure and responding gracefully. With custom error classes, structured handling, and proper monitoring, your application becomes resilient and debuggable.

AI helps implement these patterns correctly from the start—from custom error hierarchies to global handlers to user-facing messages. The result is an application that handles the unexpected without breaking, frustrating, or confusing users.

Share this article

Help spread the word about Bootspring