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 #

  1. A fresh AbortController per call. run() aborts whatever controller is currently stored in controllerRef, then creates and stores a new one. Every fetch and every delay() inside the attempt loop reads controller.signal, so a second call to run() (or an unmount) cancels the first call’s retries in one place.

  2. isRetryable() classifies the response. Only 429 and any 5xx status count as transient. A res.ok === false response with any other status — 400, 401, 404 — throws an http_<status> error that is caught and immediately passed to setError, with no retry attempted.

  3. jitterDelay() computes a capped, fully-jittered delay. The base delay doubles per attempt (300ms → 600ms → 1200ms …) and is capped at MAX_DELAY_MS. Rather than sleeping for that capped value directly, the function returns Math.random() * capped — full jitter — so concurrent clients retrying after the same failure do not converge on the same instant. A Retry-After header, when present, overrides the computed delay entirely.

  4. delay() sleeps without blocking cancellation. The promise resolves via setTimeout, but it also attaches an abort listener that clears the timeout and rejects with an AbortError. This is what makes a retry loop stop instantly on unmount instead of firing a fetch against a signal nobody is listening on anymore.

  5. The loop caps attempts and surfaces terminal errors. After MAX_ATTEMPTS - 1 retries, a still-failing transient status becomes a retries_exhausted_<status> error and is surfaced via setError rather than looping forever. Network-level failures (a rejected fetch promise, no res at all) fall into the catch block 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 #

  1. Fake-timers unit test. With vi.useFakeTimers() (or Jest’s equivalent), mock fetch to resolve 503 twice and 200 on the third call. Call run(), then advance timers with vi.advanceTimersByTimeAsync() past the expected jittered window; assert fetch was called exactly three times and data matches the final payload.

  2. Non-retryable path. Mock fetch to resolve a single 404. Assert run() resolves after exactly one fetch call and that error.message equals http_404 — proving the hook never enters the backoff loop for a client error.

  3. Abort-on-unmount test. Render the hook with @testing-library/react, call run(), unmount before the mocked delay resolves, and assert no act() warning fires and setData/setError were never called after unmount.

  4. 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_MS ceiling.

  5. Retry-After honored. Point the hook at a mock endpoint that returns 429 with Retry-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.