How to Retry Failed API Requests in React

Retry failed API requests in React with a limit and a delay. Learn which errors are worth retrying and how to add automatic or manual retries.

6 min read

Retry failed API requests in React by re-running a request after an error, usually with a limit and a short delay. Decide which failures are worth retrying, then add automatic retries or a manual Retry button. This guide covers both.

Decide what is worth retrying

Retry only errors that might go away. A network failure or a 5xx response is often temporary, so a second attempt can succeed. A 4xx response means the request itself is wrong, and retrying just repeats the same rejection.

  • Retry network failures and 5xx responses, plus 408 and 429 when the response marks them as retryable.
  • Do not retry 400, 401, 403, or 404, because the request needs to change.
  • Respect a Retry-After header when a 429 response provides one.

This decision happens in the catch path, where you either throw and stop or wait and try again. Keeping it explicit prevents a retry loop from hammering a server that is already failing. For a request that fails only occasionally, this guard is the difference between a quick recovery and an endless loop of identical failures.

Write a retry loop

A retry loop wraps the request, waits between failures, and gives up after a set number of attempts. It keeps the retry logic in one function so every caller gets the same behavior.

App.jsxApp.jsx
async function fetchWithRetry(url, { retries = 3 } = {}) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`Status ${response.status}`);
      return await response.json();
    } catch (error) {
      if (attempt === retries) throw error;
      await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
    }
  }
}

The loop tries up to three times. After each failure except the last, it waits before the next attempt, with the delay growing from one second to two. You can swap that linear delay for an exponential backoff, but a small increasing delay is enough to start.

The catch block decides whether to keep going. On the final attempt it re-throws, so the caller still sees the real error instead of an undefined result. Between attempts, the promise returned by setTimeout pauses the loop without blocking the rest of the page.

Use the loop from a component

Call the retry helper from the same Effect you would use for a plain fetch, and keep the ignore flag so a stale response cannot win. The helper hides the retry details, so the Effect reads almost the same as a single fetch.

App.jsxApp.jsx
useEffect(() => {
  let ignore = false;
  fetchWithRetry("/api/posts")
    .then((data) => {
      if (!ignore) setPosts(data);
    })
    .catch(() => { if (!ignore) setStatus("error"); });
  return () => { ignore = true; };
}, []);

The helper resolves with parsed JSON on success and rejects after the final failed attempt. The component only sees the end result, so the loading state stays true through the whole retry sequence and the user sees one continuous wait rather than a flicker between attempts.

When the helper finally rejects, the catch sets the error state for the view to display. The underlying request setup is the same one explained in how to fetch API data in React.

Let the user retry manually

A manual Retry button is often safer than automatic retries, because it does not multiply load on a struggling server. It also gives users a way to recover without waiting on an automatic schedule. Drive the Effect with an attempt counter, and render the button only in the error state.

App.jsxApp.jsx
const [attempt, setAttempt] = useState(0);
useEffect(() => {
  let ignore = false;
  fetch("/api/posts")
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setPosts(data);
    })
    .catch(() => { if (!ignore) setStatus("error"); });
  return () => { ignore = true; };
}, [attempt]);

Bumping the counter changes the dependency, so React cleans up the previous Effect and runs it again, which restarts the request from the loading state.

App.jsxApp.jsx
{status === "error" && (
  <button type="button" onClick={() => setAttempt((n) => n + 1)}>
    Retry
  </button>
)}

The button appears only after a failure and sends the view back to loading through a new attempt. Combining this with the status model from how to handle loading, error, empty, and success states keeps the screen consistent. When the user can also change the request, stop the old one first, as shown in how to cancel fetch requests in React with AbortController.

For background data with no user present, automatic retries make more sense, but keep the attempt cap and the delay.

Rune AI

Rune AI

Key Insights

  • Retry network and 5xx errors, not 4xx errors.
  • Cap the number of attempts.
  • Add a delay between attempts.
  • Guard against stale responses with an ignore flag.
  • Prefer a manual Retry button while the behavior is still settling.
RunePowered by Rune AI

Frequently Asked Questions

Which errors should I retry?

Retry network failures and 5xx responses, plus 408 and 429 when the response says a retry is safe. Do not retry 400, 401, 403, or 404, because the request itself needs to change.

Should retries be automatic or manual?

Start with a manual Retry button. Automatic retries are worth it for background loads, but they can multiply load on a failing server, so always cap the attempts and add a delay.

How do I avoid overlapping retries?

Guard the request with an ignore flag in the Effect cleanup, and disable the Retry button while a request is in flight. The cancellation guide covers AbortController for stopping the old request.

Conclusion

Retry only errors that are likely temporary, cap the number of attempts, and add a delay between them. A manual Retry button is often safer than automatic retries, especially while you learn which failures recover.