Error Handling
Problem
Failed API requests leave users viewing loading spinners indefinitely with no indication that anything went wrong, or crash components with unhandled promise rejections that propagate to the console without user-facing feedback. Network timeouts, server errors, malformed responses, and authentication failures all manifest as silent failures or cryptic error messages, with no consistent way to show error messages that help users understand what happened and what they can do about it.
Retry logic is scattered inconsistently across components, with some requests retrying automatically while others fail permanently. Applications lack graceful degradation strategies when backend services are unavailable, showing blank sections instead of cached data or simplified alternatives.
Error logging is inconsistent, making production debugging difficult when developers can’t reproduce issues. Users don’t know if failures are transient network issues they should retry or permanent errors requiring different action. Components don’t distinguish between different error types - 404s, 500s, network errors, and timeout errors all trigger the same generic “something went wrong” message.
Solution
Anticipate and handle failure cases explicitly rather than letting errors propagate uncaught, wrapping async operations in try-catch blocks or promise catch handlers.
Classify errors by type (network failure, server error, validation error, authorization error) and provide appropriate user feedback and recovery actions for each. Log errors with sufficient context to debugging services like Sentry, LogRocket, or Datadog for production troubleshooting.
Implement retry strategies for transient failures like network timeouts while avoiding retries for permanent errors like 404s. Display error states that explain what went wrong in user-friendly language and suggest actions like “Try again” or “Check your connection”.
Provide fallback content or cached data when real-time data fails to load. This creates graceful degradation where applications display helpful messages and maintain partial functionality instead of breaking completely. Store error state alongside loading and data state to manage the full request lifecycle.
Example
This example demonstrates wrapping API calls in try-catch blocks to handle errors gracefully and prevent unhandled promise rejections.
Basic Error Handling
async function fetchUser(id) {
try {
// Attempt to fetch user data
const response = await fetch(`/api/users/${id}`);
// Check if the HTTP response indicates success
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
// Log the error for debugging and monitoring
console.error('Failed to fetch user:', error);
// Re-throw to let caller decide how to handle
throw error;
}
}
Error Classification and Recovery
class APIError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
this.name = 'APIError';
}
}
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
// Parse error response
const errorData = await response.json().catch(() => ({}));
// Throw classified error
throw new APIError(
errorData.message || 'Request failed',
response.status,
errorData.code
);
}
return await response.json();
} catch (error) {
// Network error (fetch failed)
if (error instanceof TypeError) {
throw new APIError('Network error - check your connection', 0, 'NETWORK_ERROR');
}
// Re-throw API errors
if (error instanceof APIError) {
throw error;
}
// Unexpected error
throw new APIError('Unexpected error occurred', 500, 'UNKNOWN_ERROR');
}
}
React Component with Error State
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadUser() {
try {
setLoading(true);
setError(null);
const userData = await fetchUser(userId);
setUser(userData);
} catch (err) {
// Store error for display
setError(err);
// Log to monitoring service
if (window.Sentry) {
window.Sentry.captureException(err, {
tags: { userId, component: 'UserProfile' }
});
}
} finally {
setLoading(false);
}
}
loadUser();
}, [userId]);
if (loading) return <Spinner />;
if (error) {
return (
<ErrorDisplay
error={error}
onRetry={() => window.location.reload()}
/>
);
}
return <div>{user.name}</div>;
}
Centralized Error Handler
class ErrorHandler {
constructor(options = {}) {
this.logger = options.logger || console;
this.onError = options.onError;
}
async handle(error, context = {}) {
// Log error with context
this.logger.error('Error occurred:', {
error: error.message,
stack: error.stack,
...context
});
// Send to monitoring service
if (window.Sentry) {
window.Sentry.captureException(error, { contexts: context });
}
// Call custom error handler
if (this.onError) {
this.onError(error, context);
}
// Return user-friendly message
return this.getUserMessage(error);
}
getUserMessage(error) {
if (error instanceof APIError) {
switch (error.status) {
case 404:
return 'The requested resource was not found';
case 401:
return 'Please log in to continue';
case 403:
return 'You do not have permission to access this';
case 500:
return 'Server error - please try again later';
default:
return error.message || 'Something went wrong';
}
}
if (error.code === 'NETWORK_ERROR') {
return 'Network connection failed - check your internet';
}
return 'An unexpected error occurred';
}
}
// Usage
const errorHandler = new ErrorHandler({
logger: customLogger,
onError: (error) => {
// Show toast notification
showToast(error.message, 'error');
}
});
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new APIError('Fetch failed', response.status);
return await response.json();
} catch (error) {
const message = await errorHandler.handle(error, { userId: id });
throw new Error(message);
}
}
Retry Logic with Exponential Backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
const error = new APIError('Request failed', response.status);
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw error;
}
lastError = error;
} else {
return await response.json();
}
} catch (error) {
lastError = error;
// Don't retry network errors on last attempt
if (attempt === maxRetries - 1) {
throw error;
}
}
// Exponential backoff: wait 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retry attempt ${attempt + 1} after ${delay}ms`);
}
throw lastError;
}
Vue Composition API Error Handling
<script setup>
import { ref, onMounted } from 'vue';
const user = ref(null);
const loading = ref(true);
const error = ref(null);
async function loadUser(userId) {
try {
loading.value = true;
error.value = null;
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
user.value = await response.json();
} catch (err) {
error.value = err.message;
console.error('Error loading user:', err);
} finally {
loading.value = false;
}
}
async function retry() {
await loadUser(props.userId);
}
onMounted(() => {
loadUser(props.userId);
});
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">
<p>{{ error }}</p>
<button @click="retry">Try again</button>
</div>
<div v-else>{{ user.name }}</div>
</template>
Error Boundary Integration
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
async function loadData() {
try {
const result = await fetchData();
setData(result);
} catch (error) {
// Let error boundary catch it
throw error;
}
}
loadData();
}, []);
return <div>{data?.value}</div>;
}
// Wrap in error boundary
function App() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<DataComponent />
</ErrorBoundary>
);
}
Global Error Handler
// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection:', event.reason);
// Log to monitoring service
if (window.Sentry) {
window.Sentry.captureException(event.reason);
}
// Show user notification
showToast('An unexpected error occurred', 'error');
// Prevent default browser error handling
event.preventDefault();
});
// Catch synchronous errors
window.addEventListener('error', (event) => {
console.error('Global error:', event.error);
if (window.Sentry) {
window.Sentry.captureException(event.error);
}
});
Timeout Handling
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('Request timeout - please try again');
}
throw error;
}
}
Graceful Degradation
async function fetchUserWithFallback(userId) {
try {
// Try to fetch fresh data
return await fetchUser(userId);
} catch (error) {
console.warn('Failed to fetch user, using cached data:', error);
// Fall back to cached data
const cached = getCachedUser(userId);
if (cached) {
return { ...cached, stale: true };
}
// Return minimal fallback
return { id: userId, name: 'User', stale: true };
}
}
Benefits
- Prevents uncaught errors from crashing the application by wrapping risky operations in error handlers.
- Enables showing helpful error messages to users that explain what happened and suggest recovery actions.
- Provides opportunity to retry failed operations automatically or through user action for transient failures.
- Can log errors with context to monitoring services for debugging production issues developers can’t reproduce locally.
- Creates better user experience during failures by maintaining partial functionality and providing clear feedback.
- Allows graceful degradation with cached data or simplified alternatives when real-time data unavailable.
- Enables different handling strategies for different error types (retry transient failures, redirect on auth errors, show inline errors for validation).
Tradeoffs
- Adds boilerplate try-catch blocks throughout the codebase, increasing code volume and reducing the signal-to-noise ratio.
- Can hide errors if not properly logged or reported to monitoring services - swallowed errors become invisible debugging nightmares.
- May encourage swallowing errors silently without adequate user feedback or logging, creating mysterious failures.
- Requires deciding how to handle each error type - should it retry, redirect, show a modal, or display inline? Each decision requires judgment.
- Can make code more verbose and harder to read, especially when error handling dominates the happy path logic.
- Centralized error handlers can oversimplify by treating all errors the same when different contexts require different handling.
- Retry logic adds complexity around exponential backoff, max attempts, and deciding which errors are retryable vs permanent.
- Error classification requires maintaining error type hierarchies or error code mappings that must stay synchronized with backend APIs.
- Global error handlers catch errors but lack context about user action or application state, making user-facing messages generic.
- Async error handling with useEffect in React is verbose - errors must be caught and stored in state rather than propagating naturally.
- Testing error handling requires mocking failures, which can be awkward and may not cover all real-world error scenarios.
- Error messages must balance technical accuracy for developers with user-friendly language for end users, often requiring different messages for each audience.
- Deciding error handling granularity is difficult - handle at component level, service level, or globally? Each level has different context availability.
- Error logging can overwhelm monitoring services with noise if not filtered properly - every 404 or network blip doesn’t need alerting.
- Fallback strategies like cached data can create confusion when users see stale information without realizing it’s not current.