Retries, Backoff & Idempotency
Resilient calls on flaky LTE and subway mode - when to retry, how to back off, and how idempotency keys prevent duplicate charges.
Search across all documentation pages
Resilient calls on flaky LTE and subway mode - when to retry, how to back off, and how idempotency keys prevent duplicate charges.
Quick-reference recipe card - copy-paste ready.
// src/api/retry.ts
export type RetryOptions = {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
};
function jitter(ms: number): number {
return ms * (0.5 + Math.random() * 0.5);
}
export async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
opts: RetryOptions = {}
): Promise<Response> {
const {
maxAttempts = 4,
baseDelayMs = 500,
maxDelayMs = 8_000,
shouldRetry = defaultShouldRetry,
} = opts;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(input, init);
if (res.status === 429) {
const retryAfter = res.headers.get("Retry-After");
const waitSec = retryAfter ? Number(retryAfter) : 2 ** attempt;
await sleep(jitter(waitSec * 1000));
continue;
}
if (res.ok || !shouldRetry(res, attempt)) return res;
lastError = new Error(`HTTP ${res.status}`);
} catch (error) {
lastError = error;
if (!shouldRetry(error, attempt)) throw error;
}
if (attempt < maxAttempts) {
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await sleep(jitter(delay));
}
}
throw lastError;
}
function defaultShouldRetry(error: unknown, attempt: number): boolean {
if (attempt >= 4) return false;
if (error instanceof Response) {
const status = error.status;
return status >= 500 || status === 408 || status === 429;
}
if (error instanceof Error) {
if (error.name === "AbortError") return false;
return error instanceof TypeError; // network blip
}
return false;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}When to reach for this:
import NetInfo from "@react-native-community/netinfo";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { fetchWithRetry } from "../api/retry";
const API = process.env.EXPO_PUBLIC_API_URL ?? "https://api.example.com";
function idempotencyKey(): string {
return `idem-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
async function createOrder(items: { sku: string; qty: number }[], key: string) {
const res = await fetchWithRetry(
`${API}/orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ items }),
},
{
maxAttempts: 3,
shouldRetry: (err, attempt) => {
if (err instanceof Response) return err.status >= 500 && attempt < 3;
return err instanceof TypeError && attempt < 3;
},
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useCreateOrder() {
const queryClient = useQueryClient();
const waitForOnline = useCallback(async () => {
const state = await NetInfo.fetch();
if (state.isConnected && state.isInternetReachable !== false) return;
await new Promise<void>((resolve) => {
const unsub = NetInfo.addEventListener((s) => {
if (s.isConnected && s.isInternetReachable !== false) {
unsub();
resolve();
}
});
});
}, []);
return useMutation({
mutationFn: async (items: { sku: string; qty: number }[]) => {
await waitForOnline();
const key = idempotencyKey();
return createOrder(items, key);
},
retry: false, // retries handled in fetchWithRetry with idempotency key
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
});
}What this demonstrates:
Retry-After when present.retry: false on useMutation when transport-layer retry already runs with keys - avoids duplicate mutation fn calls with new keys.| Method / case | Retry? | Condition |
|---|---|---|
| GET, HEAD | ✅ Yes | Bounded attempts + backoff |
| PUT with stable id | ✅ Usually | Server upserts by resource id |
| DELETE | ✅ Usually | Second delete should 404 safely |
| POST payment | ⚠️ Only with idempotency key | Server stores key → same response |
| POST without key | ❌ No | Risk duplicate rows/charges |
| 401 / 403 | ❌ No | Refresh auth first |
| 400 validation | ❌ No | Payload is wrong - fix client |
| 409 conflict | ❌ No | Merge or show UI |
type IdempotentRequest = {
idempotencyKey: string; // client-generated UUID
operation: "createOrder" | "capturePayment";
payload: unknown;
};Idempotency-Key - align header name with providerexport const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (failureCount >= 3) return false;
if (error instanceof Error && error.message.startsWith("HTTP 4")) return false;
return true;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
},
mutations: {
retry: 0,
},
},
});retry: 0 - explicit idempotency at transport layer instead| Symptom | Likely cause | Retry strategy |
|---|---|---|
TypeError: Network request failed | Handoff, tunnel, airplane | Backoff; pause if offline |
| Hang then timeout | Weak signal | AbortController + retry |
| HTTP 502/503 | Gateway recovery | Retry with jitter |
| Intermittent 200 + empty body | CDN glitch | Parse guard; retry once |
| Success on server, timeout on client | Slow ACK | Idempotency key on POST |
for (const item of queue) {
try {
await fetchWithRetry(url, { headers: { "Idempotency-Key": item.key }, ... });
dequeue(item);
} catch {
incrementAttempts(item);
if (item.attempts > 5) surfaceToUser(item);
}
}| Alternative | Use When | Don't Use When |
|---|---|---|
Transport fetchWithRetry | Custom fetch client | Already using axios-retry with same rules |
TanStack Query retry | GET queries | Non-idempotent mutations |
| Background TaskManager replay | Large uploads hours later | Immediate user feedback required |
| Server long-polling | Client retry budget exhausted | Normal REST with short timeouts |
3–4 for reads with exponential backoff. 0–1 for mutations unless idempotency keys are guaranteed.
Randomizing delay (e.g. 500–1000 ms instead of exactly 750 ms) so thousands of devices do not retry in the same millisecond when a tower comes back.
No - the resource is missing or the URL is wrong. Retrying wastes battery and obscures bugs.
Idempotency keys tell the server to dedupe within a TTL. Client IDs become the resource id in the payload - use both for offline creates when possible.
It can, if configured with the same idempotency and status rules. Transport-agnostic utilities keep fetch and axios consistent.
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026