← Back to Blog

Error Handling Patterns in JavaScript: A Production-Ready Guide

Battle-tested error handling strategies from years of production incidents. Learn try/catch patterns, custom errors, async handling, and how to keep your users happy when things go wrong.

Sarah Chen
Sarah ChenSenior Full-Stack Developer

Let me tell you about the 3 AM incident that changed how I think about error handling.

We had just launched a major feature at my previous startup. Everything looked great in staging. Our test suite was green. We were confident. Then at 3 AM, our on-call engineer got paged: the entire checkout flow was broken. Thousands of users were seeing a blank white screen.

The root cause? A single uncaught promise rejection in a third-party payment SDK. No error message, no fallback, no graceful degradation. Just silence and a crashed application.

That night taught me something crucial: error handling is not an afterthought. It is infrastructure. It is the difference between a minor hiccup and a full-blown outage.

The Foundation: Try/Catch Done Right

Most JavaScript developers learn try/catch in their first week. Few use it effectively in production. Here is what I have learned works.

// The naive approach - catching everything try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); return processOrders(orders); } catch (error) { console.log('Something went wrong'); }

This code has three problems. First, we are catching errors from three different operations but handling them identically. Second, we are swallowing the error with a generic message. Third, we are not preserving the error for debugging.

Here is a more production-ready version:

async function getUserOrders(userId) { let user; try { user = await fetchUser(userId); } catch (error) { throw new UserFetchError(`Failed to fetch user ${userId}`, { cause: error }); } let orders; try { orders = await fetchOrders(user.id); } catch (error) { // Maybe the user exists but has no orders - that's okay if (error.code === 'NO_ORDERS_FOUND') { return []; } throw new OrderFetchError(`Failed to fetch orders for user ${userId}`, { cause: error }); } return processOrders(orders); }

Notice the difference. We handle each operation's errors specifically. We use the cause property (available since ES2022) to preserve the original error chain. We make decisions about which errors are recoverable.

Custom Error Classes: Your Secret Weapon

Generic Error objects are fine for tutorials. In production, custom error classes are invaluable. They let you categorize errors, attach metadata, and make informed decisions about handling.

class AppError extends Error { constructor(message, options = {}) { super(message, { cause: options.cause }); this.name = this.constructor.name; this.code = options.code || 'UNKNOWN_ERROR'; this.statusCode = options.statusCode || 500; this.isOperational = options.isOperational ?? true; this.metadata = options.metadata || {}; Error.captureStackTrace(this, this.constructor); } toJSON() { return { name: this.name, message: this.message, code: this.code, metadata: this.metadata }; } } class ValidationError extends AppError { constructor(message, fields = {}) { super(message, { code: 'VALIDATION_ERROR', statusCode: 400, metadata: { fields } }); } } class NotFoundError extends AppError { constructor(resource, id) { super(`${resource} with id ${id} not found`, { code: 'NOT_FOUND', statusCode: 404, metadata: { resource, id } }); } } class ExternalServiceError extends AppError { constructor(service, originalError) { super(`External service ${service} failed`, { code: 'EXTERNAL_SERVICE_ERROR', statusCode: 502, isOperational: true, cause: originalError, metadata: { service } }); } }

The isOperational flag is particularly important. It distinguishes between expected errors (user not found, validation failed) and programmer errors (null reference, type errors). This distinction matters for how you respond.

function errorHandler(error, req, res, next) { // Log everything logger.error({ error: error.toJSON?.() || { message: error.message }, stack: error.stack, requestId: req.id, path: req.path }); if (error.isOperational) { // Safe to send to client return res.status(error.statusCode).json({ error: { code: error.code, message: error.message, ...(error.metadata?.fields && { fields: error.metadata.fields }) } }); } // Programmer error - don't leak details return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } }); }

Async Error Handling: The Tricky Parts

Asynchronous code introduces complexity that trips up even experienced developers. Promise rejections, in particular, require careful attention.

// This silently swallows the error async function dangerous() { someAsyncOperation(); // Missing await! return 'done'; } // This is better async function safer() { await someAsyncOperation(); return 'done'; }

For operations where you intentionally do not await (fire-and-forget), explicitly handle the rejection:

function trackAnalytics(event) { sendToAnalyticsService(event) .catch(error => { // Analytics failures should not break the app logger.warn('Analytics tracking failed', { event, error }); }); }

When dealing with multiple concurrent operations, Promise.allSettled is often better than Promise.all:

async function fetchDashboardData(userId) { const results = await Promise.allSettled([ fetchUserProfile(userId), fetchRecentActivity(userId), fetchNotifications(userId), fetchRecommendations(userId) ]); return { profile: results[0].status === 'fulfilled' ? results[0].value : null, activity: results[1].status === 'fulfilled' ? results[1].value : [], notifications: results[2].status === 'fulfilled' ? results[2].value : [], recommendations: results[3].status === 'fulfilled' ? results[3].value : [], errors: results .filter(r => r.status === 'rejected') .map(r => r.reason) }; }

This pattern lets you show a partially loaded dashboard rather than failing completely when one service is down.

React Error Boundaries: Containing the Blast Radius

In React applications, error boundaries prevent a single component failure from taking down your entire app.

class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { // Send to error tracking service errorReporter.captureException(error, { extra: { componentStack: errorInfo.componentStack, boundary: this.props.name } }); } render() { if (this.state.hasError) { return this.props.fallback || <DefaultErrorFallback error={this.state.error} />; } return this.props.children; } }

Strategic placement matters. I recommend multiple boundaries at different levels:

function App() { return ( <ErrorBoundary name="app-root" fallback={<FullPageError />}> <Header /> <ErrorBoundary name="main-content" fallback={<ContentError />}> <main> <ErrorBoundary name="sidebar" fallback={<SidebarFallback />}> <Sidebar /> </ErrorBoundary> <ErrorBoundary name="feed" fallback={<FeedError />}> <Feed /> </ErrorBoundary> </main> </ErrorBoundary> <Footer /> </ErrorBoundary> ); }

If the sidebar crashes, users can still use the main feed. If the feed crashes, navigation still works. Graceful degradation in action.

Logging That Actually Helps

I have seen too many error logs that look like this:

ERROR: Something went wrong
ERROR: Error occurred
ERROR: null

Useless. Here is what good error logging looks like:

const logger = { error(context) { const logEntry = { timestamp: new Date().toISOString(), level: 'error', service: process.env.SERVICE_NAME, version: process.env.APP_VERSION, environment: process.env.NODE_ENV, ...context, // Ensure error is serializable error: context.error ? { name: context.error.name, message: context.error.message, code: context.error.code, stack: context.error.stack, cause: context.error.cause?.message } : null }; // In production, send to logging service if (process.env.NODE_ENV === 'production') { sendToLoggingService(logEntry); } console.error(JSON.stringify(logEntry)); } }; // Usage logger.error({ message: 'Payment processing failed', error: error, userId: user.id, orderId: order.id, amount: order.total, paymentMethod: order.paymentMethod, requestId: req.id, attemptNumber: retryCount });

Include context. Include identifiers. Include anything that will help you reproduce the issue at 3 AM.

User-Friendly Error Messages

Technical accuracy and user friendliness are different goals. Your users do not care about stack traces.

const userMessages = { VALIDATION_ERROR: 'Please check your input and try again.', NOT_FOUND: 'We could not find what you were looking for.', UNAUTHORIZED: 'Please sign in to continue.', FORBIDDEN: 'You do not have permission to do that.', RATE_LIMITED: 'Too many requests. Please wait a moment.', EXTERNAL_SERVICE_ERROR: 'We are having trouble connecting. Please try again.', INTERNAL_ERROR: 'Something went wrong on our end. We are looking into it.', NETWORK_ERROR: 'Please check your internet connection.', TIMEOUT: 'This is taking longer than expected. Please try again.' }; function getUserMessage(error) { return userMessages[error.code] || userMessages.INTERNAL_ERROR; } function ErrorDisplay({ error, onRetry }) { const userMessage = getUserMessage(error); const isRetryable = ['EXTERNAL_SERVICE_ERROR', 'NETWORK_ERROR', 'TIMEOUT'].includes(error.code); return ( <div className="error-container" role="alert"> <p>{userMessage}</p> {isRetryable && ( <button onClick={onRetry}>Try Again</button> )} {error.code === 'VALIDATION_ERROR' && error.metadata?.fields && ( <ul> {Object.entries(error.metadata.fields).map(([field, message]) => ( <li key={field}>{message}</li> ))} </ul> )} </div> ); }

Graceful Degradation Strategies

The best error handling often means avoiding errors entirely through graceful degradation.

async function getProductRecommendations(userId) { try { // Try personalized recommendations return await recommendationService.getPersonalized(userId); } catch (error) { logger.warn('Personalized recommendations failed, falling back', { userId, error }); try { // Fall back to popular items return await recommendationService.getPopular(); } catch (fallbackError) { logger.warn('Popular recommendations failed, using cached', { error: fallbackError }); // Last resort: cached static recommendations return getCachedRecommendations(); } } }

Circuit breakers prevent cascading failures:

class CircuitBreaker { constructor(options = {}) { this.failureThreshold = options.failureThreshold || 5; this.resetTimeout = options.resetTimeout || 30000; this.failures = 0; this.state = 'CLOSED'; this.nextAttempt = null; } async execute(fn) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error('Circuit breaker is open'); } this.state = 'HALF_OPEN'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failures = 0; this.state = 'CLOSED'; } onFailure() { this.failures++; if (this.failures >= this.failureThreshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.resetTimeout; } } } const paymentCircuit = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 60000 }); async function processPayment(order) { return paymentCircuit.execute(() => paymentService.charge(order)); }

Bringing It All Together

Error handling is not about preventing all errors. That is impossible. It is about building systems that fail gracefully, provide useful feedback, and help you fix issues quickly.

Here are the principles I live by:

  1. Be specific about what you catch and why
  2. Use custom errors to carry context through your system
  3. Distinguish between operational errors and programmer errors
  4. Log everything you will need to debug at 3 AM
  5. Show users messages they can actually act on
  6. Design for partial failure from the start

That 3 AM incident I mentioned? We now have error boundaries around every major feature, circuit breakers on external services, and fallbacks for non-critical functionality. The last time a payment SDK had issues, our users saw a friendly message and a retry button. Our alerting caught it. We fixed it during business hours.

That is the goal. Not perfect code, but resilient systems.

Sarah Chen
Written bySarah ChenSenior Full-Stack Developer
Read more articles