Error Recovery Patterns
Automatic and manual retry strategies for handling transient failures
Error Recovery Patterns
Not all errors are permanent. Network timeouts, rate limits, and temporary service unavailability can often be resolved by retrying. Smart retry strategies improve reliability without overwhelming systems.
When to Retry
✅ Retry-able Errors (Transient)
- Network timeouts
- 429 Too Many Requests
- 500/502/503/504 Server errors
- Connection refused
- DNS failures
- Rate limit errors
❌ Don't Retry (Permanent)
- 400 Bad Request (invalid input)
- 401 Unauthorized (needs authentication)
- 403 Forbidden (no permission)
- 404 Not Found
- 422 Unprocessable Entity
- Client-side validation errors
Basic Retry Pattern
// lib/retry.ts
export async function retry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
delay?: number;
shouldRetry?: (error: Error) => boolean;
} = {}
): Promise<T> {
const {
maxAttempts = 3,
delay = 1000,
shouldRetry = () => true,
} = options;
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
// Don't retry if we shouldn't
if (!shouldRetry(lastError)) {
throw lastError;
}
// Don't retry on last attempt
if (attempt === maxAttempts) {
throw lastError;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retry attempt ${attempt}/${maxAttempts}`);
}
}
throw lastError!;
}
// Usage
const data = await retry(
() => fetch('/api/data').then(r => r.json()),
{
maxAttempts: 3,
delay: 1000,
shouldRetry: (error) => {
// Only retry on network or 5xx errors
return error.message.includes('network') ||
error.message.includes('500');
},
}
);Exponential Backoff
// lib/exponential-backoff.ts
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
baseDelay?: number;
maxDelay?: number;
factor?: number;
jitter?: boolean;
} = {}
): Promise<T> {
const {
maxAttempts = 5,
baseDelay = 1000,
maxDelay = 30000,
factor = 2,
jitter = true,
} = options;
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw lastError;
}
// Calculate delay: baseDelay * (factor ^ attempt)
let delay = Math.min(baseDelay * Math.pow(factor, attempt - 1), maxDelay);
// Add jitter to prevent thundering herd
if (jitter) {
delay = delay * (0.5 + Math.random() * 0.5);
}
console.log(`Retry attempt ${attempt}/${maxAttempts}, waiting ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError!;
}
// Usage
// Delays: 1s, 2s, 4s, 8s, 16s (with jitter)
const data = await retryWithBackoff(
() => fetchData(),
{
maxAttempts: 5,
baseDelay: 1000,
factor: 2,
jitter: true,
}
);Retry with React Query
// hooks/useDataWithRetry.ts
import { useQuery } from '@tanstack/react-query';
export function useDataWithRetry() {
return useQuery({
queryKey: ['data'],
queryFn: fetchData,
// Retry configuration
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Only retry on network/server errors
retryOnMount: false,
retryOnReconnect: true,
// Custom retry logic
retryDelay: (attemptIndex, error) => {
// Don't retry on 4xx errors
if (error.response?.status >= 400 && error.response?.status < 500) {
return false;
}
// Exponential backoff
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});
}
// Usage
function DataComponent() {
const { data, error, isLoading, refetch } = useDataWithRetry();
if (isLoading) return <Spinner />;
if (error) return <ErrorUI onRetry={refetch} />;
return <DataDisplay data={data} />;
}SWR with Retry
// hooks/useDataSWR.ts
import useSWR from 'swr';
export function useDataSWR() {
return useSWR('/api/data', fetcher, {
// Retry configuration
errorRetryCount: 3,
errorRetryInterval: 1000,
// Conditional retry
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Never retry on 404
if (error.status === 404) return;
// Don't retry on 4xx errors
if (error.status >= 400 && error.status < 500) return;
// Max 3 retries
if (retryCount >= 3) return;
// Exponential backoff
setTimeout(() => revalidate({ retryCount }), 1000 * 2 ** retryCount);
},
});
}Manual Retry UI
// components/ManualRetry.tsx
'use client';
import { useState } from 'react';
interface Props {
onRetry: () => Promise<void>;
error: Error;
maxAttempts?: number;
}
export function ManualRetry({ onRetry, error, maxAttempts = 3 }: Props) {
const [retrying, setRetrying] = useState(false);
const [attempts, setAttempts] = useState(0);
const handleRetry = async () => {
if (attempts >= maxAttempts) {
return;
}
setRetrying(true);
setAttempts(prev => prev + 1);
try {
await onRetry();
// Success - reset
setAttempts(0);
} catch (error) {
// Failed - error boundary will catch it
} finally {
setRetrying(false);
}
};
const canRetry = attempts < maxAttempts;
return (
<div className="error-container">
<div className="error-icon">⚠️</div>
<h2>Something went wrong</h2>
<p className="error-message">{error.message}</p>
{canRetry ? (
<button
onClick={handleRetry}
disabled={retrying}
className="retry-button"
>
{retrying ? 'Retrying...' : `Try Again (${maxAttempts - attempts} left)`}
</button>
) : (
<div className="max-retries">
<p>Maximum retry attempts reached.</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
)}
<div className="retry-info">
<small>Attempt {attempts} of {maxAttempts}</small>
</div>
</div>
);
}Automatic Retry with Delay
// components/AutoRetry.tsx
'use client';
import { useEffect, useState } from 'react';
interface Props {
error: Error;
onRetry: () => void;
delay?: number;
maxAttempts?: number;
}
export function AutoRetry({ error, onRetry, delay = 3000, maxAttempts = 3 }: Props) {
const [countdown, setCountdown] = useState(delay / 1000);
const [attempts, setAttempts] = useState(1);
useEffect(() => {
if (attempts > maxAttempts) return;
// Countdown timer
const interval = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(interval);
onRetry();
setAttempts(prev => prev + 1);
return delay / 1000;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [attempts, onRetry, delay, maxAttempts]);
if (attempts > maxAttempts) {
return (
<div className="error-container">
<h2>Unable to recover</h2>
<p>Please refresh the page or contact support.</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
);
}
return (
<div className="error-container">
<div className="spinner" />
<h2>Connection issue detected</h2>
<p>Retrying automatically in {countdown} seconds...</p>
<p className="text-sm">Attempt {attempts} of {maxAttempts}</p>
<button onClick={onRetry}>Retry Now</button>
</div>
);
}Retry Queue for Failed Mutations
// lib/retry-queue.ts
interface QueueItem {
id: string;
fn: () => Promise<any>;
attempts: number;
maxAttempts: number;
lastError?: Error;
}
class RetryQueue {
private queue: Map<string, QueueItem> = new Map();
private processing = false;
add(id: string, fn: () => Promise<any>, maxAttempts = 3) {
this.queue.set(id, {
id,
fn,
attempts: 0,
maxAttempts,
});
this.process();
}
remove(id: string) {
this.queue.delete(id);
}
private async process() {
if (this.processing) return;
this.processing = true;
while (this.queue.size > 0) {
for (const [id, item] of this.queue.entries()) {
try {
await item.fn();
this.queue.delete(id);
console.log(`✓ Retry successful: ${id}`);
} catch (error) {
item.attempts++;
item.lastError = error as Error;
if (item.attempts >= item.maxAttempts) {
console.error(`✗ Max retries reached: ${id}`, error);
this.queue.delete(id);
} else {
console.log(`Retry ${item.attempts}/${item.maxAttempts}: ${id}`);
await new Promise(resolve => setTimeout(resolve, 1000 * item.attempts));
}
}
}
// Wait before next batch
if (this.queue.size > 0) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
this.processing = false;
}
getStatus() {
return {
pending: this.queue.size,
items: Array.from(this.queue.values()),
};
}
}
export const retryQueue = new RetryQueue();
// Usage - Queue failed mutations
async function updateUser(data: UserData) {
try {
await api.updateUser(data);
} catch (error) {
// Add to retry queue
retryQueue.add(`update-user-${data.id}`, () => api.updateUser(data));
throw error;
}
}Network-Aware Retry
// lib/network-aware-retry.ts
export async function retryWhenOnline<T>(fn: () => Promise<T>): Promise<T> {
// Check if online
if (!navigator.onLine) {
console.log('Offline detected, waiting for connection...');
// Wait for online event
await new Promise<void>(resolve => {
const handler = () => {
window.removeEventListener('online', handler);
resolve();
};
window.addEventListener('online', handler);
});
console.log('Back online, retrying...');
}
return fn();
}
// Usage
async function syncData() {
await retryWhenOnline(async () => {
const response = await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(localData),
});
if (!response.ok) {
throw new Error('Sync failed');
}
return response.json();
});
}Retry with Progress Indicator
// components/RetryWithProgress.tsx
'use client';
import { useState, useEffect } from 'react';
interface Props {
onRetry: () => Promise<void>;
maxAttempts: number;
}
export function RetryWithProgress({ onRetry, maxAttempts }: Props) {
const [currentAttempt, setCurrentAttempt] = useState(0);
const [retrying, setRetrying] = useState(false);
const [error, setError] = useState<Error | null>(null);
const retry = async () => {
setRetrying(true);
setCurrentAttempt(prev => prev + 1);
try {
await onRetry();
// Success
setCurrentAttempt(0);
setError(null);
} catch (err) {
setError(err as Error);
if (currentAttempt < maxAttempts - 1) {
// Auto retry after delay
const delay = 1000 * Math.pow(2, currentAttempt);
setTimeout(retry, delay);
}
} finally {
setRetrying(false);
}
};
useEffect(() => {
retry();
}, []);
const progress = (currentAttempt / maxAttempts) * 100;
return (
<div className="retry-progress">
<h3>Attempting to reconnect...</h3>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<p>
Attempt {currentAttempt} of {maxAttempts}
</p>
{error && (
<details>
<summary>Error details</summary>
<pre>{error.message}</pre>
</details>
)}
{retrying ? (
<div className="spinner" />
) : currentAttempt >= maxAttempts ? (
<button onClick={() => window.location.reload()}>
Reload Page
</button>
) : null}
</div>
);
}Retry Strategies Comparison
| Strategy | Use Case | Pros | Cons |
|---|---|---|---|
| Immediate Retry | Quick transient errors | Fast recovery | Can overwhelm system |
| Fixed Delay | Rate limiting | Predictable | May be too slow/fast |
| Exponential Backoff | Server overload | Reduces load | Can be slow |
| Exponential + Jitter | Thundering herd | Prevents spikes | Complex |
| Manual Retry | User control | No auto-retry | Requires user action |
| Network-Aware | Offline support | Smart retry | Browser-specific |
Best Practices
- Classify Errors: Only retry transient failures
- Use Exponential Backoff: Reduce load on failing systems
- Add Jitter: Prevent thundering herd problem
- Set Max Attempts: Don't retry forever
- Show Progress: Keep users informed
- Allow Manual Override: Let users retry immediately
- Queue Failed Requests: Retry when back online
- Log Retry Attempts: Track retry patterns
Common Mistakes
❌ Retrying 4xx errors: They won't succeed
✅ Only retry transient failures
❌ Infinite retries: Wastes resources
✅ Set max attempts
❌ Fixed delay: Can cause issues
✅ Use exponential backoff
❌ Silent retries: User doesn't know what's happening
✅ Show retry progress
When to Use Each Pattern
Automatic Retry: For background operations, API calls
Manual Retry: For user-initiated actions, form submissions
Queue Retry: For offline-first apps, sync operations
Network-Aware: For mobile apps, PWAs
Smart retry strategies turn transient failures into seamless user experiences.