API Retry Patterns in JavaScript: Full Tutorial
A complete tutorial on API retry patterns in JavaScript. Covers exponential backoff with jitter, linear backoff, retry budgets, idempotency keys, conditional retry based on error type, retry with circuit breaker integration, retry queues, and testing retry logic with deterministic timers.
Network requests fail. Servers return 503. Connections time out. A robust retry strategy handles transient failures gracefully without overwhelming the server or creating duplicate operations. This guide covers every retry pattern you need for production JavaScript applications.
When to Retry
| Error Type | Should Retry | Reason |
|---|---|---|
| Network error (no response) | Yes | Transient connectivity issue |
| HTTP 408 Request Timeout | Yes | Server was too slow |
| HTTP 429 Too Many Requests | Yes (with Retry-After) | Rate limit, temporary |
| HTTP 500 Internal Server Error | Yes | Transient server issue |
| HTTP 502 Bad Gateway | Yes | Upstream server issue |
| HTTP 503 Service Unavailable | Yes | Server overloaded or in maintenance |
| HTTP 504 Gateway Timeout | Yes | Upstream timeout |
| HTTP 400 Bad Request | No | Client sent invalid data |
| HTTP 401 Unauthorized | No | Invalid credentials |
| HTTP 403 Forbidden | No | Insufficient permissions |
| HTTP 404 Not Found | No | Resource does not exist |
| HTTP 409 Conflict | No | Business logic conflict |
| HTTP 422 Unprocessable | No | Validation failure |
Basic Retry With Exponential Backoff
async function retryWithBackoff(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
shouldRetry = isRetryableError,
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries || !shouldRetry(error)) {
throw error;
}
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
console.warn(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms`);
await sleep(delay);
}
}
}
function isRetryableError(error) {
if (!error.response) return true; // Network error
const status = error.response.status || error.statusCode;
return [408, 429, 500, 502, 503, 504].includes(status);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}Exponential Backoff With Jitter
Without jitter, all clients that failed at the same time retry at the same time, creating a thundering herd. Jitter adds randomness to spread retries out.
function calculateDelay(attempt, baseDelay, maxDelay, jitterStrategy = "full") {
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const capped = Math.min(exponentialDelay, maxDelay);
switch (jitterStrategy) {
case "none":
return capped;
case "full":
// Random between 0 and capped
return Math.random() * capped;
case "equal":
// Half fixed + half random
return capped / 2 + Math.random() * (capped / 2);
case "decorrelated": {
// Each delay is random between base and 3x the previous delay
const prev = attempt === 0 ? baseDelay : baseDelay * Math.pow(2, attempt - 1);
return Math.min(maxDelay, Math.random() * (prev * 3 - baseDelay) + baseDelay);
}
default:
return capped;
}
}Jitter Strategies Compared
| Strategy | Formula | Spread | Best For |
|---|---|---|---|
| None | base * 2^attempt | Zero | Testing only |
| Full | random(0, base * 2^attempt) | Maximum | High-contention APIs |
| Equal | base * 2^attempt / 2 + random(0, half) | Moderate | General purpose |
| Decorrelated | random(base, prev * 3) | Progressive | AWS-recommended |
async function retryWithJitter(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
jitter = "full",
shouldRetry = isRetryableError,
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries || !shouldRetry(error)) throw error;
const delay = calculateDelay(attempt, baseDelay, maxDelay, jitter);
await sleep(delay);
}
}
}Retry With 429 Retry-After
async function retryWithRateLimit(fn, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
if (error.status === 429 || error.response?.status === 429) {
const retryAfter = parseRetryAfter(error);
console.warn(`Rate limited. Waiting ${retryAfter}ms`);
await sleep(retryAfter);
} else if (isRetryableError(error)) {
const delay = calculateDelay(attempt, 1000, 30000, "full");
await sleep(delay);
} else {
throw error; // Non-retryable
}
}
}
}
function parseRetryAfter(error) {
const header = error.response?.headers?.get?.("Retry-After")
|| error.headers?.["retry-after"];
if (!header) return 5000; // Default 5 seconds
const seconds = parseInt(header, 10);
if (!isNaN(seconds)) return seconds * 1000;
// HTTP date format
const date = new Date(header);
return Math.max(0, date.getTime() - Date.now());
}Retry Budget
A retry budget limits the total number of retries across all requests in a time window, preventing retry storms:
class RetryBudget {
constructor(maxRetries, windowMs) {
this.maxRetries = maxRetries;
this.windowMs = windowMs;
this.retries = [];
}
canRetry() {
const now = Date.now();
this.retries = this.retries.filter((t) => now - t < this.windowMs);
return this.retries.length < this.maxRetries;
}
recordRetry() {
this.retries.push(Date.now());
}
remaining() {
const now = Date.now();
this.retries = this.retries.filter((t) => now - t < this.windowMs);
return this.maxRetries - this.retries.length;
}
}
const budget = new RetryBudget(20, 60000); // Max 20 retries per minute
async function retryWithBudget(fn, maxAttempts = 3) {
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts || !isRetryableError(error)) throw error;
if (!budget.canRetry()) {
console.error("Retry budget exhausted");
throw error;
}
budget.recordRetry();
const delay = calculateDelay(attempt, 1000, 30000, "full");
await sleep(delay);
}
}
}Idempotency Keys
POST requests that are retried can create duplicate resources. Idempotency keys prevent this:
function generateIdempotencyKey() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
async function safePost(url, data, options = {}) {
const idempotencyKey = options.idempotencyKey || generateIdempotencyKey();
return retryWithJitter(
() => fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(data),
}).then((r) => {
if (!r.ok) {
const error = new Error(`HTTP ${r.status}`);
error.response = r;
throw error;
}
return r.json();
}),
{ maxRetries: 3, jitter: "equal" }
);
}
// Usage: same key ensures at-most-once processing
await safePost("/api/payments", { amount: 4999, currency: "USD" });Retry With Abort Signal
async function retryWithAbort(fn, signal, options = {}) {
const { maxRetries = 3, baseDelay = 1000 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
try {
return await fn(signal);
} catch (error) {
if (error.name === "AbortError") throw error;
if (attempt === maxRetries || !isRetryableError(error)) throw error;
const delay = calculateDelay(attempt, baseDelay, 30000, "full");
// Abortable sleep
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
}
}
// Usage
const controller = new AbortController();
const result = await retryWithAbort(
(signal) => fetch("/api/data", { signal }),
controller.signal,
{ maxRetries: 3 }
);See using AbortController in JS complete tutorial for cancellation patterns.
Fetch Retry Wrapper
async function fetchWithRetry(url, options = {}) {
const {
retries = 3,
retryDelay = 1000,
retryOn = [408, 429, 500, 502, 503, 504],
onRetry = null,
...fetchOptions
} = options;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, fetchOptions);
if (retryOn.includes(response.status) && attempt < retries) {
const delay = response.status === 429
? (parseInt(response.headers.get("Retry-After") || "5", 10)) * 1000
: calculateDelay(attempt, retryDelay, 30000, "full");
if (onRetry) onRetry(attempt + 1, response.status, delay);
await sleep(delay);
continue;
}
return response;
} catch (error) {
if (attempt === retries) throw error;
const delay = calculateDelay(attempt, retryDelay, 30000, "full");
if (onRetry) onRetry(attempt + 1, 0, delay);
await sleep(delay);
}
}
}
// Usage
const response = await fetchWithRetry("/api/users", {
retries: 3,
retryDelay: 1000,
onRetry: (attempt, status, delay) => {
console.log(`Retry ${attempt}: status=${status}, waiting ${delay}ms`);
},
});Rune AI
Key Insights
- Always use jitter with exponential backoff: Without jitter, synchronized retries create thundering herds that worsen the outage
- Never retry 4xx errors (except 429): Client errors indicate the request itself is wrong; retrying produces the same failure
- Idempotency keys make POST retries safe: The server uses the key to detect and deduplicate repeated requests
- Retry budgets prevent retry storms: A global limit ensures your app does not amplify server load when many requests fail simultaneously
- Respect Retry-After headers: The server knows its recovery timeline better than your backoff algorithm; use the header when provided
Frequently Asked Questions
How many times should I retry?
What is jitter and why does it matter?
Should I retry POST requests?
What is a retry budget?
How do I test retry logic?
Conclusion
Production retry patterns require exponential backoff with jitter to avoid thundering herds, retry budgets to cap global retry volume, idempotency keys to prevent duplicate mutations, Retry-After header parsing for rate limits, and abort signal integration for cancellability. For the error classification that determines which errors to retry, see advanced API error handling in JS full guide. For the promise combinators used in retry logic, see advanced JS promise patterns complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.