Skip to main content
Saved

Pattern Resilience Advanced

Retry

When a request fails because of a temporary network problem, your app can try it again with exponential backoff, so users don’t see an error for a problem that fixes itself.

Examples
React and native web
Read
4 min

The problem

Your user’s WiFi dropped out for 200 milliseconds, and now they’re looking at an error message for a request that would have worked fine if the app had just tried again automatically.

The pattern

With automatic retry logic, a request that fails with a temporary error, such as a 5xx response or a network failure, is tried again before any error is shown. For a brief glitch, the retry usually succeeds and users never find out that anything went wrong.

Problem

This drives me crazy. Many applications treat temporary failures, such as network blips, server timeouts, deployment rollouts, and container restarts, exactly the same way as permanent ones. A two-second hiccup gets the same error message as a resource that doesn’t exist, even though the hiccup would have fixed itself within seconds.

It’s worst for mobile users. Cellular connections often drop packets, and switching from WiFi to mobile data can cause brief outages. If every failure triggers an error dialog, mobile users might see error messages over and over on a normal connection.

Solution

Exponential backoff spaces the retries out, waiting 1 second before the first retry, 2 seconds before the second, and 4 seconds before the third. That way, your app doesn’t flood a struggling server with requests, but the first retry still happens quickly. Adding a random amount of time to each delay, called jitter, stops thousands of clients from retrying at the same moment and overloading a server that’s just recovering.

Not every error is worth retrying. A 404 tells you the resource doesn’t exist, so retrying won’t help. A 401 tells you the user isn’t authenticated, and retrying won’t change that until they log in. You should only retry temporary errors, and only up to a maximum number of retries, because a request that fails five times in a row probably has a permanent problem.

Example

These examples start with exponential backoff and jitter, then build up to a React hook, a circuit breaker, and support for 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],
    onRetry
  } = options;

  // One initial attempt plus up to maxRetries retries
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) return await response.json();

      const error = new Error(`Request failed: ${response.status}`);
      error.retryable = retryableStatuses.includes(response.status);
      throw error;
    } catch (error) {
      // fetch() rejects with a TypeError on network failure, which is worth retrying
      const retryable = error.retryable ?? error instanceof TypeError;
      if (!retryable || attempt === maxRetries) throw error;

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

React Hook with Retry

If you wrap the retry logic in a hook, your components get a simple way to fetch data 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);
      setError(null);
      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 altogether once the number of failures passes a threshold, so your app doesn’t keep sending requests to a service that’s already down.

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 the server rate-limits a request, its Retry-After header tells the client how long to wait, and it’s better to follow that than to use your own backoff timing.

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

    // Retry rate limits and server errors; other client errors fail straight away
    const retryable = response.status === 429 || response.status >= 500;
    if (!retryable || attempt === maxRetries) {
      throw new Error(`Request failed: ${response.status}`);
    }

    let delay = Math.pow(2, attempt) * 1000;
    const retryAfter = response.headers.get('Retry-After');
    if (retryAfter) {
      // Retry-After can be seconds or HTTP date
      delay = isNaN(retryAfter)
        ? Math.max(0, new Date(retryAfter) - Date.now())
        : parseInt(retryAfter, 10) * 1000;
    }
    await new Promise(resolve => setTimeout(resolve, delay));
  }
}

User Feedback During Retry

If you tell users about retries, they know the app is still working and hasn’t locked up.

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

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

  if (error) return <div>Couldn't load this content. Please try again later.</div>;
  if (!data) {
    return (
      <div>
        Loading...
        {attempt > 0 && <p>Connection issue, retrying (attempt {attempt})...</p>}
      </div>
    );
  }
  return <div>{data.content}</div>;
}

Benefits

  • Users don’t see errors for network glitches that resolve within seconds.
  • Mobile users stop seeing constant error messages, because when a cellular connection drops briefly, the request gets retried instead of showing an error.
  • Users don’t notice when a server is briefly unavailable during a deployment or an infrastructure change.
  • Rate limits are easier to work with when you follow the server’s Retry-After header.
  • Read operations are easy to make reliable, because GET requests are safe to retry as many times as you need.
  • For temporary problems, users no longer have to click “Try Again” themselves.

Tradeoffs

  • When there’s a real problem, the error message is delayed, because users have to wait through all the retries before they find out something is broken.
  • Without jitter, you can get a “thundering herd” problem, where thousands of clients retry at the same moment and overload a server that’s trying to recover.
  • Tuning the retry settings is tricky, because retrying too aggressively adds load to a server that’s already struggling, and retrying too cautiously makes users wait too long.
  • Retries can hide real infrastructure problems, because if an endpoint is flaky but the retries eventually succeed, your team might never notice.
  • Delays grow quickly with exponential backoff, from 1s to 2s, 4s, 8s, and then 16s, and by the fifth retry users may think the app has locked up.
  • You need to be careful with non-idempotent operations, because retrying a POST can create duplicates if the first request succeeded but the response was lost.
  • A circuit breaker adds complexity, because it needs extra state to keep track of when to stop retrying.
  • On metered mobile connections, failed retries still use data the user pays for.

Summary

Retry logic tries a failed request again when it might succeed on a second attempt, so users don’t see errors for temporary network problems. Exponential backoff with jitter keeps the retries from overwhelming a server that’s recovering, and you should only retry operations that are safe to repeat. Permanent errors, such as a 404, should still fail straight away, so users find out about them quickly.

See also

Next in Resilience Stop Generation A stop button aborts a streaming model response with an AbortController, keeps the partial text, and stops the work on the server too.

Newsletter

A monthly email
from Den Odell.

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

No spam. Unsubscribe anytime.