Skip to main content
Saved
Pattern
Difficulty Advanced

Retry

Re-attempt failed requests automatically with exponential backoff strategies.

By Den Odell Added

Retry

Problem

Your user’s WiFi glitched for 200 milliseconds and now they see an error message for something that would work fine if the app just tried again automatically.

This drives me crazy. Transient failures—network blips, server timeouts, deployment rollouts, container restarts—look identical to permanent failures in most applications. A 2-second hiccup gets the same treatment as “this resource doesn’t exist,” and users see errors for problems that would resolve themselves within seconds.

Mobile users have it worst. Cellular connections drop packets constantly, and WiFi-to-mobile transitions cause brief outages. If every failure triggers an error dialog, mobile users live in error-message hell for perfectly normal network conditions.

Solution

Implement automatic retry logic: when a request fails with a transient error (5xx, timeout, network failure), try again before showing an error. Most of the time the retry succeeds and users never know anything went wrong.

The key technique is exponential backoff—wait 1 second before the first retry, 2 seconds before the second, 4 before the third. This avoids hammering a struggling server while staying responsive. Add random jitter to prevent thousands of clients from retrying at the same time and overwhelming a recovering server.

Be smart about which errors you retry. A 404 means the resource doesn’t exist—retrying won’t help. A 401 means you’re not authenticated—retrying without fixing auth is pointless. Only retry genuinely transient errors, and set a maximum retry count; if something fails five times, it’s probably permanent.

Example

Here’s retry logic from basic to sophisticated: exponential backoff with jitter, circuit breakers, and respecting rate limit headers.

Exponential Backoff with Jitter

async function fetchWithRetry(url, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    retryableStatuses = [408, 429, 500, 502, 503, 504]
  } = options;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) return await response.json();

      if (!retryableStatuses.includes(response.status)) {
        throw new Error(`Non-retryable error: ${response.status}`);
      }
      throw new Error(`Retryable error: ${response.status}`);
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;

      // Exponential backoff with ±25% jitter
      const exponentialDelay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
      const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5);
      await new Promise(resolve => setTimeout(resolve, exponentialDelay + jitter));
    }
  }
}

React Hook with Retry

Wrapping retry logic in a hook provides a clean API for data fetching with automatic retries:

function useDataWithRetry(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [retryCount, setRetryCount] = useState(0);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      setLoading(true);
      try {
        const result = await fetchWithRetry(url, { maxRetries: 3 });
        if (!cancelled) setData(result);
      } catch (err) {
        if (!cancelled) setError(err);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchData();
    return () => { cancelled = true; };
  }, [url, retryCount]);

  return { data, loading, error, retry: () => setRetryCount(c => c + 1) };
}

Retry with Circuit Breaker

A circuit breaker stops retrying entirely when failures exceed a threshold—preventing wasted requests to a known-broken service.

class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.nextAttempt = Date.now();
  }

  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.failureCount = 0;
      this.state = 'CLOSED';
      return result;
    } catch (error) {
      this.failureCount++;
      if (this.failureCount >= this.threshold) {
        this.state = 'OPEN';
        this.nextAttempt = Date.now() + this.timeout;
      }
      throw error;
    }
  }
}

Retry-After Header Support

When rate-limited, respect the server’s Retry-After header instead of using your own backoff timing.

async function fetchWithRetryAfter(url, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) return await response.json();

      if (response.status === 429 && attempt < maxRetries - 1) {
        const retryAfter = response.headers.get('Retry-After');
        if (retryAfter) {
          // Retry-After can be seconds or HTTP date
          const delay = isNaN(retryAfter)
            ? new Date(retryAfter) - Date.now()
            : parseInt(retryAfter) * 1000;
          await new Promise(resolve => setTimeout(resolve, delay));
          continue;
        }
      }

      if (response.status >= 500) throw new Error(`Server error: ${response.status}`);
      throw new Error(`Client error: ${response.status}`);
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
    }
  }
}

User Feedback During Retry

Show users what’s happening during retries so they know the app is working, not frozen.

function DataLoader({ url }) {
  const [attempt, setAttempt] = useState(0);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchWithRetry(url, {
      maxRetries: 3,
      onRetry: (attemptNum) => setAttempt(attemptNum)
    }).then(setData);
  }, [url]);

  if (!data) {
    return (
      <div>
        Loading...
        {attempt > 0 && <p>Connection issue, retrying (attempt {attempt})...</p>}
      </div>
    );
  }
  return <div>{data.content}</div>;
}

Benefits

  • Network glitches become invisible—users never see errors for problems that resolve in seconds.
  • Mobile users stop suffering constant error messages; flaky cellular connections get handled gracefully.
  • Your app survives server deployments and infrastructure changes because brief unavailability gets papered over automatically.
  • Rate limiting becomes manageable when you respect Retry-After headers.
  • Read operations become bulletproof since GET requests are safe to retry any number of times.
  • User friction drops significantly—no more manual “Try Again” clicks for temporary problems.

Tradeoffs

  • Error messages get delayed when there’s a real problem—users wait through all retries before finding out something is broken.
  • Without jitter, you create thundering herd problems where thousands of clients retry simultaneously and crush recovering servers.
  • Tuning parameters is tricky: too aggressive wastes resources and worsens server problems; too conservative makes users wait too long.
  • Retry logic can mask real infrastructure problems—if endpoints are flaky but retries eventually succeed, you might never notice.
  • Delays grow quickly with exponential backoff; by the fifth retry (1s, 2s, 4s, 8s, 16s), users may think the app is frozen.
  • Never retry non-idempotent operations carelessly—retrying a POST can create duplicates if the first request succeeded but the response was lost.
  • Circuit breakers add complexity; knowing when to stop retrying requires additional state management.
  • Mobile data costs add up—failed retries still consume bandwidth on metered connections.

Summary

Retry logic re-attempts failed requests that might succeed on a second try, hiding transient network issues from users. Use exponential backoff with jitter to avoid overwhelming recovering servers, and only retry operations safe to repeat. The goal: make temporary failures invisible while failing fast on permanent errors.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.