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.
Table of Contents
- 1. Why a single failed request does not mean the end
- 2. How exponential backoff works mathematically
- 3. The basic skeleton of the useFetchWithRetry hook
- 4. Not every error deserves a retry
- 5. Distinguishing this from TanStack Query's built-in retry
- 6. Offering the user a manual retry button
- 7. Testing backoff logic reliably
- 8. UX considerations: don't leave users in the dark during a retry
- 9. Conclusion: custom solution versus library
- 10. Summary
- 11. FAQ
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.