Error Handling
Error handling strategies, debugging techniques, and crash recovery for frontend applications
Error Handling Troubleshooting
Effective error handling is the difference between a professional application and one that frustrates users. This guide covers common error handling failures and systematic solutions.
1. Unhandled Promise Rejections
Problem
// Promise rejection goes unhandled — silently fails
fetch('/api/data').then(res => res.json()).then(data => setData(data));
// User sees no error, but data never loads
// Console: Unhandled promise rejection: TypeError: Failed to fetchRoot Cause
- Missing
.catch()on promises asyncfunction errors not caught- Event handlers swallowing errors
Solution
Always handle errors in async operations:
// With async/await
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
// Network error — no connectivity
showNotification('Network error. Please check your connection.');
} else {
// Server error
showNotification('Failed to load data. Please try again.');
}
reportError(error);
return null;
}
}Global unhandled rejection handler:
// Catch-all for unhandled rejections
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault(); // Prevent console error
reportError({
type: 'unhandled_rejection',
reason: event.reason,
promise: event.promise,
});
});
// Catch-all for uncaught errors
window.addEventListener('error', (event) => {
reportError({
type: 'uncaught_error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error,
});
});2. Network Error Handling — No Retry or Timeout
Problem
- API calls fail silently on network issues
- No retry mechanism for transient failures
- Requests hang indefinitely without timeout
- Users stuck on loading state forever
Solution
Fetch wrapper with timeout and retry:
async function fetchWithRetry(
url: string,
options: RequestInit & { retries?: number; timeout?: number } = {}
): Promise<Response> {
const { retries = 3, timeout = 10000, ...fetchOptions } = options;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) return response;
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw new HttpError(response.status, await response.text());
}
// Retry server errors (5xx)
if (attempt === retries) {
throw new HttpError(response.status, 'Max retries exceeded');
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof HttpError) throw error;
if (attempt === retries) throw error;
// Exponential backoff
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
throw new Error('Unreachable');
}
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'HttpError';
}
}TanStack Query built-in retry:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 5 * 60 * 1000,
},
},
});3. Error Boundaries Don't Catch Everything
Problem
React Error Boundaries don't catch:
- Errors in event handlers
- Errors in async code (promises,
setTimeout) - Errors in server-side rendering
- Errors thrown in the error boundary itself
Solution
Layer error handling strategies:
// 1. Error Boundary — catches render errors
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>
// 2. Event handler errors — try/catch
function handleSubmit() {
try {
processForm();
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error');
reportError(error);
}
}
// 3. Async errors — caught by data fetching libraries
const { error } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
// TanStack Query catches async errors automatically
});
// 4. Global fallback
window.addEventListener('error', globalErrorHandler);
window.addEventListener('unhandledrejection', globalRejectionHandler);Custom hook for error handling in event handlers:
function useErrorHandler() {
const [error, setError] = useState<Error | null>(null);
// Throw in render to trigger Error Boundary
if (error) throw error;
const handleError = useCallback((error: unknown) => {
setError(error instanceof Error ? error : new Error(String(error)));
}, []);
const wrapAsync = useCallback(
<T extends (...args: any[]) => Promise<any>>(fn: T) =>
async (...args: Parameters<T>) => {
try {
return await fn(...args);
} catch (error) {
handleError(error);
}
},
[handleError]
);
return { handleError, wrapAsync };
}
// Usage
function UploadForm() {
const { wrapAsync } = useErrorHandler();
const handleUpload = wrapAsync(async (file: File) => {
await uploadFile(file); // If this throws, Error Boundary catches it
});
return <button onClick={() => handleUpload(file)}>Upload</button>;
}4. Poor Error Messages — Users Don't Know What to Do
Problem
Error: Something went wrong
Error: null
Error: [object Object]
Error: 500Users see cryptic messages with no actionable guidance.
Solution
User-facing vs developer-facing errors:
// Error classification
class AppError extends Error {
constructor(
public userMessage: string, // What the user sees
public technicalMessage: string, // What developers see in logs
public code: string, // Machine-readable error code
public isRetryable: boolean = false,
) {
super(technicalMessage);
this.name = 'AppError';
}
}
// Error factory
const errors = {
networkError: () => new AppError(
'Unable to connect. Please check your internet connection and try again.',
'Network request failed — possible DNS/connectivity issue',
'NETWORK_ERROR',
true,
),
notFound: (resource: string) => new AppError(
`The ${resource} you're looking for could not be found.`,
`404: Resource ${resource} not found`,
'NOT_FOUND',
false,
),
serverError: () => new AppError(
'Our servers are experiencing issues. Please try again in a few minutes.',
'Server returned 5xx status',
'SERVER_ERROR',
true,
),
};Error UI with clear actions:
function ErrorDisplay({ error }: { error: AppError }) {
return (
<div role="alert" className="error-container">
<h2>Oops!</h2>
<p>{error.userMessage}</p>
<div className="error-actions">
{error.isRetryable && (
<button onClick={() => window.location.reload()}>Try Again</button>
)}
<button onClick={() => navigator.clipboard.writeText(error.code)}>
Copy Error Code: {error.code}
</button>
<a href="/support">Contact Support</a>
</div>
</div>
);
}5. Console Errors in Production — No Visibility
Problem
- Errors only visible in browser console — no production visibility
- No alerting when error rate increases
- Can't reproduce issues without user's browser
Solution
Structured error reporting:
// Error reporting service integration
interface ErrorReport {
message: string;
stack?: string;
componentStack?: string;
url: string;
userAgent: string;
timestamp: number;
userId?: string;
metadata: Record<string, unknown>;
}
function reportError(error: unknown, metadata: Record<string, unknown> = {}) {
const report: ErrorReport = {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
userId: getCurrentUserId(),
metadata,
};
// Send to error tracking service (Sentry, Datadog, etc.)
Sentry.captureException(error, {
extra: metadata,
tags: { component: metadata.component as string },
});
// Also log for local development
if (import.meta.env.DEV) {
console.error('[Error Report]', report);
}
}Source maps for readable stack traces (see Build & Deploy guide).
6. Debugging Difficult Issues
Problem
- Bug only reproduces in production
- Bug is intermittent and non-deterministic
- Bug depends on specific user data or state
Solution
Structured debugging approach:
// 1. Reproduce: Add query-param debug mode
if (new URLSearchParams(location.search).has('debug')) {
localStorage.setItem('debug', 'true');
}
// 2. Isolate: Conditional logging
const debug = {
log: (...args: unknown[]) => {
if (localStorage.getItem('debug') === 'true') {
console.log('[DEBUG]', new Date().toISOString(), ...args);
}
},
table: (data: unknown) => {
if (localStorage.getItem('debug') === 'true') {
console.table(data);
}
},
trace: (label: string) => {
if (localStorage.getItem('debug') === 'true') {
console.trace(`[TRACE] ${label}`);
}
},
};Session replay for production debugging:
Integrate session replay tools (LogRocket, FullStory, Sentry Replay) to see exactly what the user experienced:
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: 'https://...',
integrations: [
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
replaysSessionSampleRate: 0.1, // 10% of sessions
replaysOnErrorSampleRate: 1.0, // 100% of error sessions
});React DevTools Profiler for performance debugging:
import { Profiler } from 'react';
function onRender(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
) {
if (actualDuration > 16) { // Slower than 60fps
console.warn(`Slow render: ${id} took ${actualDuration}ms (${phase})`);
}
}
<Profiler id="ExpensiveComponent" onRender={onRender}>
<ExpensiveComponent />
</Profiler>7. API Error Handling Inconsistency
Problem
Different parts of the application handle API errors differently:
- Some show toast notifications
- Some show inline errors
- Some silently fail
- Some redirect to error pages
Solution
Centralized API error handling layer:
// api/client.ts — single source of truth for API error handling
class APIClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
switch (response.status) {
case 401:
// Redirect to login
window.location.href = '/login';
throw new AppError('Session expired', 'AUTH_EXPIRED', false);
case 403:
throw new AppError('You don\'t have permission', 'FORBIDDEN', false);
case 404:
throw new AppError('Resource not found', 'NOT_FOUND', false);
case 422:
throw new ValidationError(body.errors);
case 429:
throw new AppError('Too many requests. Please wait.', 'RATE_LIMIT', true);
default:
throw new AppError('Server error. Please try again.', 'SERVER_ERROR', true);
}
}
return response.json();
}
}Summary: Error Handling Architecture
┌──────────────────────┐
│ Global Error │
│ Handlers │
│ (window.onerror) │
└──────┬───────────────┘
│
┌──────▼───────────────┐
│ Error Boundaries │
│ (React render) │
└──────┬───────────────┘
│
┌────────────┼────────────────┐
│ │ │
┌──────▼──────┐ ┌──▼──────────┐ ┌───▼─────────┐
│ API Layer │ │ Event │ │ Async │
│ try/catch │ │ Handlers │ │ Operations │
│ + retry │ │ try/catch │ │ AbortCtrl │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└────────────┼────────────────┘
│
┌──────▼───────────────┐
│ Error Reporting │
│ (Sentry, etc.) │
└──────────────────────┘