This page drills into one narrow failure inside Custom Hooks for Async Error Catching: what a fetch-wrapping hook does the instant a request fails, before any promise-rejection handling even enters the picture.
The exact failure this page solves #
A component calls fetch() inside a useEffect, the request hits a transient 503 from an overloaded upstream or a mid-flight network blip, and the hook’s catch block fires once and gives up — the user sees a permanent error banner for a problem that would have resolved itself a second later. Naively wrapping the call in a while loop is worse: it retries a 401 just as eagerly as a 503, and if every client retries on the same fixed delay, a recovering server gets hit with a synchronized retry storm the moment it comes back up. The fix is a useRetryableFetch hook that classifies failures before retrying, backs off exponentially with full jitter capped at a sane ceiling, respects a server-supplied Retry-After header when present, and gives up cleanly — via AbortSignal — the moment the component unmounts or a newer call supersedes it.
Zero-to-working implementation #
The hook below is complete and framework-agnostic beyond the useState/useCallback/useRef imports. It retries only on network errors, 429, and 5xx; everything else — a 404, a 401, a malformed body — surfaces to error on the first attempt.
// useRetryableFetch.ts
import { useCallback, useRef, useState } from 'react';
const MAX_ATTEMPTS = 5;
const BASE_DELAY_MS = 300;
const MAX_DELAY_MS = 8000; // ceiling so backoff never grows unbounded
function isRetryable(status: number): boolean {
return status === 429 || (status >= 500 && status < 600);
}
// Full jitter: a random value in [0, cappedDelay), not just the capped value itself
function jitterDelay(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return retryAfterMs;
const capped = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
return Math.random() * capped;
}
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(new DOMException('Aborted', 'AbortError'));
const id = setTimeout(resolve, ms);
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
export function useRetryableFetch<T>() {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const run = useCallback(async (input: RequestInfo, init?: RequestInit) => {
controllerRef.current?.abort(); // cancel any superseded in-flight call
const controller = new AbortController();
controllerRef.current = controller;
setError(null);
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const res = await fetch(input, { ...init, signal: controller.signal });
if (res.ok) return setData((await res.json()) as T);
if (!isRetryable(res.status)) throw new Error(`http_${res.status}`);
if (attempt === MAX_ATTEMPTS - 1) throw new Error(`retries_exhausted_${res.status}`);
const retryAfter = res.headers.get('Retry-After');
const ms = retryAfter ? Number(retryAfter) * 1000 : undefined;
await delay(jitterDelay(attempt, ms), controller.signal);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith('http_') || attempt === MAX_ATTEMPTS - 1) return setError(err as Error);
await delay(jitterDelay(attempt), controller.signal); // network error — retry too
}
}
}, []);
const cancel = useCallback(() => controllerRef.current?.abort(), []);
return { data, error, run, cancel };
}
Step-by-step explanation #
-
A fresh
AbortControllerper call.run()aborts whatever controller is currently stored incontrollerRef, then creates and stores a new one. Everyfetchand everydelay()inside the attempt loop readscontroller.signal, so a second call torun()(or an unmount) cancels the first call’s retries in one place. -
isRetryable()classifies the response. Only429and any5xxstatus count as transient. Ares.ok === falseresponse with any other status —400,401,404— throws anhttp_<status>error that is caught and immediately passed tosetError, with no retry attempted. -
jitterDelay()computes a capped, fully-jittered delay. The base delay doubles per attempt (300ms → 600ms → 1200ms …) and is capped atMAX_DELAY_MS. Rather than sleeping for that capped value directly, the function returnsMath.random() * capped— full jitter — so concurrent clients retrying after the same failure do not converge on the same instant. ARetry-Afterheader, when present, overrides the computed delay entirely. -
delay()sleeps without blocking cancellation. The promise resolves viasetTimeout, but it also attaches anabortlistener that clears the timeout and rejects with anAbortError. This is what makes a retry loop stop instantly on unmount instead of firing afetchagainst a signal nobody is listening on anymore. -
The loop caps attempts and surfaces terminal errors. After
MAX_ATTEMPTS - 1retries, a still-failing transient status becomes aretries_exhausted_<status>error and is surfaced viasetErrorrather than looping forever. Network-level failures (a rejectedfetchpromise, noresat all) fall into thecatchblock and are retried the same way, up to the same cap.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
| No jitter, plain exponential backoff | All clients retry at the exact same tick after a shared outage — a thundering herd against the recovering server | jitterDelay() always samples Math.random() * capped, spreading retries across the full window instead of a single instant |
Retry-After header present on a 429 |
A fixed backoff schedule ignores the server’s explicit guidance and either retries too soon or waits longer than necessary | Read res.headers.get('Retry-After'), convert seconds to milliseconds, and pass it into jitterDelay() to override the computed delay entirely |
| Component unmounts mid-retry | A setData/setError call after unmount throws a React warning, or a stray fetch completes against a component nobody renders anymore |
controller.abort() runs in the effect’s cleanup (or before a new run() call); delay() and fetch both reject with AbortError, which the catch block special-cases into an early return with no state update |
Two calls to run() fired in quick succession |
The first call’s response wins the race and overwrites the second call’s fresher data | run() unconditionally aborts controllerRef.current before starting, so only the most recent call can ever call setData |
Verification steps #
-
Fake-timers unit test. With
vi.useFakeTimers()(or Jest’s equivalent), mockfetchto resolve503twice and200on the third call. Callrun(), then advance timers withvi.advanceTimersByTimeAsync()past the expected jittered window; assertfetchwas called exactly three times anddatamatches the final payload. -
Non-retryable path. Mock
fetchto resolve a single404. Assertrun()resolves after exactly onefetchcall and thaterror.messageequalshttp_404— proving the hook never enters the backoff loop for a client error. -
Abort-on-unmount test. Render the hook with
@testing-library/react, callrun(), unmount before the mocked delay resolves, and assert noact()warning fires andsetData/setErrorwere never called after unmount. -
DevTools network throttling. In Chrome DevTools, set Network to “Offline” for one attempt, then restore it. Watch the Network panel timing column — the gap between the failed request and the retry should visibly grow per attempt and never exceed the
MAX_DELAY_MSceiling. -
Retry-After honored. Point the hook at a mock endpoint that returns
429withRetry-After: 2, and confirm in the Network panel that the retry fires roughly two seconds later rather than following the computed exponential schedule.
Frequently asked questions #
Why use full jitter instead of plain exponential backoff?
Plain exponential backoff without randomization causes every client that failed at the same moment to retry at the same moment again, creating a synchronized wave of load against a recovering server. Full jitter picks a random delay between zero and the capped exponential value, spreading retries out and reducing the peak concurrent load on the backend.
Should I retry a 401 or 403 response?
No. Authentication and authorization failures are not transient — retrying them wastes attempts and delays the user from seeing an actionable error. The hook’s isRetryable check only matches 429 and 5xx statuses, so 4xx responses other than 429 throw immediately and populate the error state on the first attempt.
What happens to a pending retry if the component unmounts?
The AbortController tied to that call is aborted, which rejects the in-flight fetch or the pending delay() promise with an AbortError. The hook’s catch block special-cases AbortError and returns without calling setState, so there is no state update on an unmounted component and no orphaned timer keeps running.
Related #
- Custom Hooks for Async Error Catching — parent guide covering promise-rejection interception and error normalization across React, Vue, and Svelte hooks
- Framework-Specific Crash Recovery & Error Handlers — the broader section on catching and recovering from crashes at the framework layer
- Catching Unhandled Promise Rejections Globally in React — a sibling guide for rejections that escape any local
try/catch, including a failed retry loop that isn’t awaited