const DEFAULTS = {
baseDelay: 300,
maxDelay: 8000,
factor: 2,
jitter: 0.5,
};
export function computeDelay(attempt, opts = {}) {
const { baseDelay, maxDelay, factor, jitter } = { ...DEFAULTS, ...opts };
const raw = baseDelay * Math.pow(factor, attempt);
const capped = Math.min(raw, maxDelay);
const rand = 1 - jitter + Math.random() * jitter * 2;
return Math.round(capped * rand);
}
export function isRetryable(error) {
if (error && error.name === 'AbortError') return false;
const status = error && error.status;
if (status == null) return true; // network / fetch failure
if (status === 429) return true;
return status >= 500 && status < 600;
}
export function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
}
});
}
import { useCallback, useEffect, useReducer, useRef } from 'react';
import { computeDelay, isRetryable, sleep } from './backoff';
const initial = { status: 'idle', error: null, data: null, attempt: 0 };
function reducer(state, action) {
switch (action.type) {
case 'start':
return { ...initial, status: 'loading' };
case 'retry':
return { ...state, status: 'retrying', attempt: action.attempt, error: action.error };
case 'success':
return { status: 'success', error: null, data: action.data, attempt: state.attempt };
case 'error':
return { ...state, status: 'error', error: action.error };
default:
return state;
}
}
export function useMutation(mutationFn, { maxAttempts = 4, backoff } = {}) {
const [state, dispatch] = useReducer(reducer, initial);
const mountedRef = useRef(true);
const controllerRef = useRef(null);
useEffect(() => () => { mountedRef.current = false; }, []);
const safeDispatch = useCallback((action) => {
if (mountedRef.current) dispatch(action);
}, []);
const mutate = useCallback(async (variables) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
safeDispatch({ type: 'start' });
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const data = await mutationFn(variables, { signal: controller.signal });
safeDispatch({ type: 'success', data });
return data;
} catch (error) {
const lastTry = attempt === maxAttempts - 1;
if (!isRetryable(error) || lastTry) {
safeDispatch({ type: 'error', error });
throw error;
}
safeDispatch({ type: 'retry', attempt: attempt + 1, error });
await sleep(computeDelay(attempt, backoff), controller.signal);
}
}
}, [mutationFn, maxAttempts, backoff, safeDispatch]);
const cancel = useCallback(() => controllerRef.current?.abort(), []);
return { ...state, mutate, cancel, maxAttempts };
}
import React from 'react';
export default function MutationStatus({ mutation, onRetry, label = 'Save' }) {
const { status, attempt, maxAttempts, error } = mutation;
if (status === 'loading') {
return <span className="ms ms--busy" role="status">{label}ing\u2026</span>;
}
if (status === 'retrying') {
return (
<span className="ms ms--retry" role="status">
Retrying ({attempt}/{maxAttempts})\u2026
</span>
);
}
if (status === 'success') {
return <span className="ms ms--ok">Saved</span>;
}
if (status === 'error') {
const reason = error?.status ? `HTTP ${error.status}` : 'Network error';
return (
<span className="ms ms--fail" role="alert">
Failed ({reason}).{' '}
<button type="button" onClick={onRetry}>Try again</button>
</span>
);
}
return null;
}
import React, { useCallback } from 'react';
import { useMutation } from './useMutation';
import MutationStatus from './MutationStatus';
async function saveProfile(payload, { signal }) {
const res = await fetch('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': payload.id },
body: JSON.stringify(payload),
signal,
});
if (!res.ok) {
const err = new Error(`Request failed: ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
export default function SaveProfileForm({ profile }) {
const mutation = useMutation(saveProfile, { maxAttempts: 4 });
const submit = useCallback((e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
mutation.mutate({ id: profile.id, name: form.get('name') }).catch(() => {});
}, [mutation, profile.id]);
const busy = mutation.status === 'loading' || mutation.status === 'retrying';
return (
<form onSubmit={submit}>
<input name="name" defaultValue={profile.name} disabled={busy} />
<button type="submit" disabled={busy}>Save</button>
<MutationStatus
mutation={mutation}
onRetry={() => mutation.mutate({ id: profile.id, name: profile.name })}
/>
</form>
);
}
This snippet shows how to make write requests (POST/PUT/DELETE) resilient in a React app by wrapping fetch in a custom useMutation hook that retries transient failures with exponential backoff, and pairing it with a small MutationStatus component that surfaces retry progress to the user.
The core idea is that network calls fail for reasons that are often temporary: a flaky connection, a 503 during a deploy, or a rate limit. Retrying immediately usually just hammers an already-struggling server. Exponential backoff spaces retries out — roughly base * 2^attempt — so each successive wait doubles, and adding jitter avoids the thundering-herd problem where many clients retry in lockstep.
In backoff.js, computeDelay calculates the wait for a given attempt, capping it at maxDelay and mixing in random jitter. isRetryable decides whether an error is worth retrying at all: network errors and 5x/429 responses are retried, while 4xx client errors (a bad payload, a 403) are not, since replaying them changes nothing. This distinction matters — blindly retrying everything wastes time and can mask real bugs.
In useMutation.js, the hook exposes a mutate function plus reactive status, error, and attempt state. The retry loop lives in run, which awaits computeDelay between tries and bails early on non-retryable errors or when maxAttempts is exhausted. It also wires an AbortController so an in-flight request can be cancelled, and a mountedRef guard prevents state updates after the component unmounts — a common source of React warnings. The reducer-driven state keeps status transitions explicit: idle, loading, retrying, success, error.
In MutationStatus.jsx, the presentational component reads that state to show a spinner, a retry counter like Retrying (2/4)…, or a failure message with a manual retry button. Keeping presentation separate from the retry mechanics means the same hook can drive very different UIs.
A typical use is a save-profile form or a checkout submit where losing the write is worse than waiting a moment. The trade-off is latency on the unhappy path and the need to keep mutations idempotent, since a retried request may actually have succeeded server-side before the response was lost.
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
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
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
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
Share this code
Here's the card — post it anywhere.