Front-end Engineering Lab

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

StrategyUse CaseProsCons
Immediate RetryQuick transient errorsFast recoveryCan overwhelm system
Fixed DelayRate limitingPredictableMay be too slow/fast
Exponential BackoffServer overloadReduces loadCan be slow
Exponential + JitterThundering herdPrevents spikesComplex
Manual RetryUser controlNo auto-retryRequires user action
Network-AwareOffline supportSmart retryBrowser-specific

Best Practices

  1. Classify Errors: Only retry transient failures
  2. Use Exponential Backoff: Reduce load on failing systems
  3. Add Jitter: Prevent thundering herd problem
  4. Set Max Attempts: Don't retry forever
  5. Show Progress: Keep users informed
  6. Allow Manual Override: Let users retry immediately
  7. Queue Failed Requests: Retry when back online
  8. 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.

On this page