const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
export function fullJitter(attempt, baseDelay = 300, maxDelay = 10_000) {
const ceiling = Math.min(maxDelay, baseDelay * 2 ** attempt);
return Math.random() * ceiling;
}
export function isRetryable(errorOrResponse) {
if (errorOrResponse instanceof Response) {
return RETRYABLE_STATUS.has(errorOrResponse.status);
}
// fetch rejects with TypeError on network failure; AbortError on timeout
const name = errorOrResponse?.name;
return name === 'TypeError' || name === 'AbortError';
}
export function retryAfterMs(response) {
const header = response.headers.get('Retry-After');
if (!header) return null;
const seconds = Number(header);
if (!Number.isNaN(seconds)) return seconds * 1000;
const date = Date.parse(header);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
export function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
import { fullJitter, isRetryable, retryAfterMs, sleep } from './backoff.js';
export async function fetchWithRetry(url, options = {}) {
const {
retries = 4,
baseDelay = 300,
maxDelay = 10_000,
timeout = 8_000,
signal: externalSignal,
...fetchOptions
} = options;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const onExternalAbort = () => controller.abort();
externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
try {
const response = await fetch(url, { ...fetchOptions, signal: controller.signal });
if (response.ok || !isRetryable(response) || attempt === retries) {
return response;
}
lastError = new Error(`Request failed with status ${response.status}`);
const serverDelay = retryAfterMs(response);
await sleep(serverDelay ?? fullJitter(attempt, baseDelay, maxDelay), externalSignal);
} catch (err) {
if (externalSignal?.aborted) throw err; // caller cancelled: stop retrying
lastError = err;
if (!isRetryable(err) || attempt === retries) throw err;
await sleep(fullJitter(attempt, baseDelay, maxDelay), externalSignal);
} finally {
clearTimeout(timer);
externalSignal?.removeEventListener('abort', onExternalAbort);
}
}
throw lastError;
}
import { useEffect, useState } from 'react';
import { fetchWithRetry } from './fetchWithRetry.js';
export function useFetchWithRetry(url, options = {}) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
let active = true;
setState({ data: null, error: null, loading: true });
fetchWithRetry(url, { ...options, signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (active) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (error.name === 'AbortError' || !active) return;
setState({ data: null, error, loading: false });
});
return () => {
active = false;
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
return state;
}
This snippet shows a small, reusable retry layer around fetch that survives flaky networks and transient server errors without hammering the backend. The core idea is exponential backoff: after each failed attempt the wait grows geometrically (base * 2^attempt), which spreads retries out and gives an overloaded service time to recover. On top of that it adds full jitter — instead of every client waiting exactly the same computed delay, each waits a random value between zero and that ceiling. Jitter is what prevents the thundering-herd problem where thousands of clients that failed at the same instant all retry in lockstep and re-synchronize the outage.
In backoff.js, fullJitter computes the capped exponential ceiling with Math.min(maxDelay, baseDelay * 2 ** attempt) and then samples a random point below it. isRetryable encodes the policy for what deserves another try: network errors (a thrown TypeError from fetch), request timeouts, and the standard transient HTTP statuses 408, 429, and 5xx. Client errors like 400 or 404 are deliberately not retried because repeating them only wastes time.
fetchWithRetry.js is the workhorse. It wraps each attempt in its own AbortController so a slow request can be cancelled once timeout elapses, translating a hang into a retryable error. It respects a server-supplied Retry-After header on 429/503 responses, honoring backpressure instead of guessing. When an attempt fails and retries remain, it sleeps for the jittered delay and loops; when attempts are exhausted it throws the last error. The signal passed by the caller is also observed, so an unmounting component can cancel the whole retry chain.
useFetchWithRetry.js adapts this into an idiomatic React hook. It tracks data, error, and loading, creates an AbortController in the effect, and aborts it on cleanup — critical for avoiding state updates after unmount. The trade-off worth noting is that retries multiply latency and can mask real failures, so callers should cap retries and maxDelay sensibly. This pattern is the right reach whenever a call touches an unreliable dependency: third-party APIs, rate-limited endpoints, or mobile connections.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.