Fetch Retry with Exponential Backoff in React Hooks | Mironsoft
AI generated
{ }
React 19 · Hooks · Data Fetching
Fetch Retry with Exponential Backoff
building a custom useFetchWithRetry hook for unstable APIs

Unstable APIs, brief network outages, or overloaded servers mean a single failed request does not automatically indicate a real error. A custom useFetchWithRetry hook with exponential backoff retries failed requests with growing wait times, without necessarily needing an extra library.

15 min read Custom Hook Exponential Backoff Error Handling

1. Why a single failed request does not mean the end

Not every failed network request is a permanent error. A brief mobile network disconnect, a server that is currently being redeployed and unreachable for a few seconds, or an overloaded endpoint responding with 503 Service Unavailable are typical, transient states. If an application immediately shows an error message for every such case, it appears more fragile than the underlying infrastructure actually is, and users have to manually retrigger the action even though an automatic second attempt often would have sufficed.

A retry mechanism addresses exactly this problem by automatically retrying failed requests before presenting the user with a final error. Crucially, retrying should not happen immediately and without pause, since that would further burden an already overloaded server and, in the worst case, delay recovery. This is exactly where exponential backoff comes in: the wait time between attempts grows with every failed attempt, giving the server time to recover instead of bombarding it with a flood of immediate retries.

2. How exponential backoff works mathematically

The basic formula for exponential backoff is deceptively simple: the wait time before the nth retry attempt is calculated as baseDelay * 2^(n-1). With a base delay of 500 milliseconds, this yields 500ms for the first attempt, 1000ms for the second, 2000ms for the third, and 4000ms for the fourth. This exponential growth rate ensures that the load on the server drops quickly during sustained problems, while early, brief hiccups still get compensated for promptly.

In practice, this base formula is usually supplemented with two additional factors: an upper bound on the maximum wait time, so the delay does not grow indefinitely, and a random jitter component that slightly varies the calculated wait time. Jitter matters because, without it, many simultaneously failed clients would retry at exactly the same moment, causing synchronized load spikes, known as the thundering herd problem. A random component of roughly 20 to 30 percent of the calculated wait time spreads retries out over time and noticeably mitigates this problem.


function calculateBackoffDelay(attempt, baseDelay = 500, maxDelay = 10000) {
  const exponentialDelay = baseDelay * 2 ** (attempt - 1);
  const cappedDelay = Math.min(exponentialDelay, maxDelay);
  const jitter = cappedDelay * 0.25 * Math.random();
  return Math.round(cappedDelay + jitter);
}

// Example values: 500-625ms, 1000-1250ms, 2000-2500ms, 4000-5000ms, ...

3. The basic skeleton of the useFetchWithRetry hook

The hook encapsulates three responsibilities: actually executing the fetch request, the retry logic with backoff calculation, and managing the visible state for loading, error, and result. The outward interface stays deliberately simple, a component calls useFetchWithRetry(url, options) and gets back data, loading, error, and the number of attempts made so far, without having to worry about the details of the retry logic.

Internally, the hook uses a recursive or loop-based function that, on failure, checks whether the maximum number of attempts has already been reached. If not, it waits via setTimeout for the calculated backoff duration and retries the request. If the maximum has been reached, the last error is finally passed to the state so the component can display a meaningful error message.


function useFetchWithRetry(url, { maxRetries = 3, baseDelay = 500 } = {}) {
  const [state, setState] = useState({ data: null, loading: true, error: null, attempt: 0 });

  useEffect(() => {
    const controller = new AbortController();
    let cancelled = false;

    async function fetchWithRetry(attempt) {
      try {
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        if (!cancelled) setState({ data, loading: false, error: null, attempt });
      } catch (err) {
        if (err.name === "AbortError" || cancelled) return;

        if (attempt >= maxRetries) {
          setState({ data: null, loading: false, error: err, attempt });
          return;
        }

        const delay = calculateBackoffDelay(attempt + 1, baseDelay);
        setState((prev) => ({ ...prev, attempt: attempt + 1 }));
        setTimeout(() => {
          if (!cancelled) fetchWithRetry(attempt + 1);
        }, delay);
      }
    }

    setState({ data: null, loading: true, error: null, attempt: 0 });
    fetchWithRetry(0);

    return () => {
      cancelled = true;
      controller.abort();
    };
  }, [url, maxRetries, baseDelay]);

  return state;
}

4. Not every error deserves a retry

A common beginner mistake is retrying every failed request indiscriminately, regardless of the actual root cause. A 401 Unauthorized or 403 Forbidden will not suddenly turn into a success on an immediate retry with the same credentials, nor will a 400 Bad Request indicating malformed input data. Such client errors are structural and are not fixed by retrying; a retry here just wastes time and delays the correct error message that should actually be shown to the user immediately.

A retry makes sense, on the other hand, for server errors like 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, and 504 Gateway Timeout, as well as for pure network errors where the request never even reached the server. A robust hook therefore distinguishes between retryable and non-retryable errors and aborts immediately on client errors instead of unnecessarily waiting several seconds before the user even gets feedback.


const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);

function isRetryableError(error) {
  if (error instanceof TypeError) return true; // network error, e.g. offline
  if (error.status) return RETRYABLE_STATUS_CODES.has(error.status);
  return false;
}

// Inside the catch block of fetchWithRetry:
// if (!isRetryableError(err) || attempt >= maxRetries) {
//   setState({ data: null, loading: false, error: err, attempt });
//   return;
// }

5. Distinguishing this from TanStack Query's built-in retry

TanStack Query already ships with retry logic including exponential backoff by default, configurable via the retry and retryDelay options when creating a query. Teams already using TanStack Query in their project, for example for caching, deduplication of parallel requests, or automatic refetching on focus change, should use this built-in functionality instead of building retry logic separately in parallel. The library additionally covers edge cases such as the interplay between retry and query invalidation or offline detection, which are easy to overlook in a custom implementation.

A custom useFetchWithRetry hook makes sense when a project deliberately works without a data-fetching library, for example because the feature scope of caching and query invalidation is not needed, the bundle should stay as small as possible, or only a single, isolated use case such as a critical form submission needs to become more robust. For a single, well-scoped problem, a lean custom solution is often more maintainable than an extra dependency whose full feature breadth goes unused.

6. Offering the user a manual retry button

Besides the automatic background retry, it makes sense to give users a manual way to try again once all automatic attempts are exhausted. This is especially important because an automatic retry with a limited number of attempts eventually has to give up, while a user might well know that the underlying cause has since changed, for example because Wi-Fi reception has returned or a known server outage has been fixed.

The hook should therefore return a retry function that resets the internal counter and starts the fetch process from scratch. This function can be bound directly to a visible button shown only in the error state. It matters to actually reset the attempt counter to zero on a manually triggered retry instead of continuing it, since a manual click is a deliberate new attempt by the user and should not immediately run up against the limit of automatic attempts.


function useFetchWithRetry(url, options = {}) {
  const [resetKey, setResetKey] = useState(0);
  // ... state and fetchWithRetry as before, useEffect additionally depends on resetKey

  const retry = useCallback(() => setResetKey((k) => k + 1), []);

  // ... return { ...state, retry };
}

function ProductFeed() {
  const { data, loading, error, retry } = useFetchWithRetry("/api/products");

  if (loading) return <p>Loading...</p>;
  if (error) {
    return (
      <div role="alert">
        <p>Products could not be loaded.</p>
        <button onClick={retry}>Try again</button>
      </div>
    );
  }
  return <ProductList items={data} />;
}

7. Testing backoff logic reliably

Testing retry logic with real wait times would be impractically slow in a test suite, which is why Jest's fake timers are well suited for artificially fast-forwarding time without actually waiting. You mock fetch so it fails on the first calls and only succeeds on the last attempt, enable jest.useFakeTimers(), and advance time after every expected failure with jest.advanceTimersByTimeAsync() by the calculated backoff duration.

A second important test verifies that non-retryable errors, such as a 401, are passed through immediately without retrying. This test ensures that the distinction between retryable and non-retryable errors actually takes effect and does not accidentally retry every error indiscriminately, which would mean unnecessary wait time for the user on unambiguous client errors.


test("returns data after a successful third attempt", async () => {
  jest.useFakeTimers();
  global.fetch = jest
    .fn()
    .mockRejectedValueOnce(new TypeError("network error"))
    .mockRejectedValueOnce(new TypeError("network error"))
    .mockResolvedValueOnce({ ok: true, json: async () => ({ items: [] }) });

  const { result } = renderHook(() => useFetchWithRetry("/api/products"));

  await jest.advanceTimersByTimeAsync(1000);
  await jest.advanceTimersByTimeAsync(2500);

  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.data).toEqual({ items: [] });
  expect(global.fetch).toHaveBeenCalledTimes(3);

  jest.useRealTimers();
});

8. UX considerations: don't leave users in the dark during a retry

A technically correct retry mechanism can still create a poor user experience if the user only sees an unchanging loading indicator during the retry attempts. With several seconds of total wait time across all attempts, an application without any visible feedback feels sluggish or even frozen. The hook's returned attempt counter can be used to transparently show the user that a retry is currently in progress, for example with text like "Attempt 2 of 3".

For very long maximum backoff times, it is also worth giving the user, after the first failed attempt, the option to manually cancel or immediately force a new attempt, instead of forcing them to passively wait for the next automatic attempt. This transparency turns a technically necessary mechanism into a comprehensible, trustworthy user experience instead of a black box.

9. Conclusion: custom solution versus library

A custom useFetchWithRetry hook with exponential backoff is a manageable, maintainable solution for projects that deliberately work without an extra data-fetching library or want to make just a single, well-scoped use case more robust. The core building blocks, backoff calculation with jitter, distinguishing retryable from non-retryable errors, and a manual retry for the user, can be implemented and thoroughly tested with manageable effort.

As soon as a project already uses TanStack Query or a comparable library, however, its built-in retry logic is generally the better choice, since it covers additional edge cases and does not need to be maintained redundantly in parallel. The decision between building it yourself and using a library should therefore depend less on basic technical feasibility, which exists in both cases, and more on whether a data-fetching library is already present or sensible in the project.

Aspect Custom useFetchWithRetry hook TanStack Query Recommendation
Extra dependency No Yes Custom hook for a small bundle
Caching and deduplication No Yes TanStack Query when caching is needed
Exponential backoff with jitter Implemented yourself Built in Both possible
Edge cases like offline detection Must be covered yourself Built in TanStack Query more robust
Suited for A single use case The app's entire data layer Depends on project size

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

Fetch Retry with Exponential Backoff: The Essentials at a Glance

Core idea

Automatically retry failed requests with a growing wait time.

Backoff formula

baseDelay times 2 to the power of (attempt minus 1), plus jitter, with a cap.

Key rule

Retry only server and network errors, report client errors immediately.

Library boundary

If TanStack Query is already present, use its built-in retry.

11. FAQ: Fetch Retry with Exponential Backoff: The Essentials at a Glance

1What is exponential backoff?
Exponential backoff is a retry strategy where the wait time between consecutive retry attempts grows exponentially with every failure, typically following the formula baseDelay times 2 to the power of (attempt minus 1).
2Why does backoff also need a jitter component?
Without jitter, many simultaneously failed clients would retry at exactly the same moment, causing synchronized load spikes. A random component spreads retries out over time and mitigates this thundering herd problem.
3Should every failed request be retried?
No, client errors like 401 or 400 are not fixed by retrying, since they have structural causes such as missing authorization or invalid input data. Only server and network errors should be retried.
4How do I distinguish retryable from non-retryable errors?
You check the HTTP status code against a list of retryable codes such as 408, 429, 500, 502, 503, and 504, and whether it is a pure network error where the server was never reached at all.
5When should I use a custom useFetchWithRetry hook instead of TanStack Query?
When a project deliberately works without a data-fetching library, the bundle should stay small, or only a single, isolated use case needs to become more robust without requiring caching or query invalidation.
6Does TanStack Query cover more edge cases than a custom solution?
Yes, TanStack Query additionally accounts for aspects such as offline detection and the interplay between retry and query invalidation, which are easy to overlook in a lean custom solution.
7How do I test backoff timings without waiting real seconds?
Jest's fake timers let you artificially fast-forward time by enabling jest.useFakeTimers() and advancing time after every expected failure with jest.advanceTimersByTimeAsync() by the calculated backoff duration.
8Why is a manual retry button useful despite an automatic retry?
Because an automatic retry with a limited number of attempts eventually has to give up, while a user might know the underlying cause has since changed, for example after Wi-Fi reception was restored.
9Should the attempt counter continue on a manual retry?
No, a manually triggered retry should reset the attempt counter to zero, since it represents a deliberate new attempt by the user that should not immediately run into the automatic limit again.
10How do I prevent users from perceiving the retry process as frozen?
The hook should return the current attempt counter so the component can transparently show that a retry is currently underway, for example with a visible message like Attempt 2 of 3.