API Retry Patterns in JavaScript: Full Tutorial
Learn how to retry failed API calls in JavaScript. Implement exponential backoff, jitter, retry budgets, and decide which errors are worth retrying vs failing fast.
API retry patterns automatically repeat a failed call, hoping the failure was temporary. Networks drop packets, servers get overloaded for a moment, and rate limits reset a few seconds later. Retrying gives these transient failures a chance to resolve on their own, without the user ever seeing an error message for a problem that fixed itself almost immediately.
The simplest retry is a fixed-delay loop:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
} catch (error) {
if (attempt === maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}This retries every failure after a 1-second wait, up to three times. It works, but it has problems: it retries errors that will never succeed (like 404), it always waits exactly 1 second, and all clients that hit the same outage retry at the same time. The rest of this article fixes each of those problems one at a time, building up to a version that is actually safe to run in production.
Deciding which errors to retry
Not every error is worth retrying. The rule is to retry transient errors and fail fast on permanent ones, starting with the network failures fetch itself throws before a response even exists. A request that fails because the connection dropped might succeed a second later, but a request that fails because of a malformed URL will fail exactly the same way every single time.
function isRetryable(error, response) {
if (error && error.name === "TypeError") {
return true; // Network errors are always retryable
}
if (!response) return false;
return response.status === 429 || response.status >= 500;
}| Status | Retry? | Reason |
|---|---|---|
| Network error | Yes | Connection dropped, DNS failure, timeout |
| 429 Too Many Requests | Yes | Rate limit will reset |
| 500-599 Server Error | Yes | Server may recover |
| 408 Request Timeout | Yes | Server was too slow |
| 400 Bad Request | No | Your request is invalid |
| 401 Unauthorized | No | Retrying will not fix credentials |
| 403 Forbidden | No | You lack permission |
| 404 Not Found | No | The resource does not exist |
| 409 Conflict | No | State conflict needs manual resolution |
Never retry a request that mutates state (POST, PUT, PATCH, DELETE) unless the API is explicitly idempotent. Retrying a POST that created a resource might create a duplicate, since the first attempt may have actually succeeded on the server even though the response never made it back to the client. The safest assumption for a write request that timed out is that you genuinely do not know whether it worked.
Exponential backoff
Instead of waiting the same time between each retry, double the delay each time: 1 second, then 2 seconds, then 4 seconds. A small helper turns the attempt number into that doubling delay.
function backoffDelay(attempt) {
return Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s
}The retry loop itself checks whether the failure is worth retrying, then waits using that helper before looping again, growing the delay on each pass instead of hammering the server at a fixed interval.
async function fetchWithBackoff(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetch(url, options);
if (response.ok) return response.json();
if (!isRetryable(null, response) || attempt === maxRetries) throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries || !isRetryable(error)) throw error;
}
await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
}
}The delay grows: 1 second after the first failure, 2 seconds after the second, 4 seconds after the third. This gives the server breathing room.
If the outage is brief, the first retry succeeds almost right away. If it is longer, the growing delay prevents hammering an already-struggling server while it recovers, instead of adding to the load that caused the outage in the first place.
Adding jitter
If hundreds of clients hit the same outage and all retry on the same exponential schedule, they synchronize and hammer the server together the moment it comes back up, sometimes causing it to fall over again immediately. Jitter adds randomness to spread the retries out over time instead of letting them land in one coordinated burst.
function backoffWithJitter(attempt, baseDelayMs = 1000) {
const exponentialDelay = Math.pow(2, attempt) * baseDelayMs;
const jitter = Math.random() * 0.5 * exponentialDelay; // 0 to 50% of the delay
return exponentialDelay - jitter;
}For attempt 2, the exponential delay is 4 seconds. With jitter subtracted, the actual delay lands somewhere between 2 and 4 seconds, so no two clients wait the exact same amount of time even if they all failed at the exact same moment.
async function fetchWithJitter(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetch(url, options);
if (response.ok) return response.json();
if (!isRetryable(null, response) || attempt === maxRetries) throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries || !isRetryable(error)) throw error;
}
await new Promise((resolve) => setTimeout(resolve, backoffWithJitter(attempt)));
}
}Each wait period is a range, not a fixed number. The randomness spreads retries across clients, preventing synchronized bursts of traffic.
Respecting Retry-After headers
When a server returns 429 (Too Many Requests) or 503 (Service Unavailable), it often includes a Retry-After header telling you how long to wait. Use that value instead of your calculated delay when it is present.
function retryAfterDelay(response, attempt) {
const retryAfter = response.headers.get("Retry-After");
return retryAfter
? parseInt(retryAfter, 10) * 1000 // Server-specified delay
: Math.pow(2, attempt) * 1000; // Fallback exponential delay
}The retry loop calls fetch directly, so it can inspect the response headers even on a failed status before deciding how long to wait.
async function handleNetworkError(error, attempt, maxRetries) {
if (attempt === maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}The loop tries the network error path first, since a rejected fetch never even reaches the point of having a response to inspect.
async function handleServerError(response, attempt, maxRetries) {
if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryAfterDelay(response, attempt)));
return true;
}
return false;
}With both error paths factored out, the loop itself just decides which helper applies to the current response and moves on to the next attempt when either one signals a retry.
async function fetchWithRetryAfter(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const response = await fetch(url, options).catch((err) => err);
if (response instanceof Error) {
await handleNetworkError(response, attempt, maxRetries);
continue;
}
if (response.ok) return response.json();
if (await handleServerError(response, attempt, maxRetries)) continue;
throw new Error(`HTTP ${response.status}`);
}
}The Retry-After header can be a number of seconds or an HTTP-date. For simplicity, this example handles the seconds format, since that is what most rate-limited APIs actually send in practice. A production implementation should parse both, falling back to the exponential delay whenever the header is missing or in a format the client does not recognize.
Setting a total time budget
Instead of a fixed number of retries, set a total time budget. The function stops retrying when the budget is exhausted, regardless of how many attempts were made. A small helper caps the next delay at whatever time remains in the budget.
function budgetedDelay(attempt, deadline) {
return Math.min(Math.pow(2, attempt) * 1000, deadline - Date.now());
}The main loop checks the deadline before every attempt, then uses that capped delay so the last wait never overshoots the budget.
async function attemptWithinBudget(url, options, deadline, attempt) {
if (Date.now() >= deadline) {
throw new Error("Retry budget exhausted");
}
try {
const response = await fetch(url, options);
if (response.ok) return { done: true, value: await response.json() };
if (!isRetryable(null, response)) throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (!isRetryable(error)) throw error;
}
return { done: false };
}The main loop just calls that helper repeatedly, waiting the capped delay between attempts until it either gets a result or the budget runs out, at which point it throws instead of looping forever.
async function fetchWithBudget(url, options = {}, budgetMs = 15000) {
const deadline = Date.now() + budgetMs;
for (let attempt = 0; ; attempt += 1) {
const result = await attemptWithinBudget(url, options, deadline, attempt);
if (result.done) return result.value;
const delay = budgetedDelay(attempt, deadline);
if (delay <= 0) throw new Error("Retry budget exhausted");
await new Promise((resolve) => setTimeout(resolve, delay));
}
}The budget pattern is more predictable than a retry count. You know the operation will either succeed or fail within 15 seconds, regardless of how many attempts fit in that window, which matters when a caller needs to show a loading state with a bounded worst case rather than an open-ended one.
Avoiding retrying non-idempotent requests
A request is idempotent if making it multiple times has the same effect as making it once. GET, HEAD, and OPTIONS are always idempotent, since they only read data and never change server state no matter how many times they run.
PUT and DELETE are idempotent by specification, because replacing a resource with the same data twice or deleting an already-deleted resource leaves the server in the same state either way. POST and PATCH are not guaranteed to be idempotent, since a POST that appends to a list or a PATCH that increments a counter produces a different result on every call.
// Safe: GET is idempotent, retrying is fine
const user = await fetchWithRetry(`/api/users/${id}`);A POST that creates a resource is a different story, since retrying it blindly risks creating the same order twice if the first attempt actually succeeded but the response was lost.
// Dangerous: POST might create duplicates on retry
// Only retry if you know the endpoint is idempotent
const order = await fetchWithRetry("/api/orders", {
method: "POST",
body: JSON.stringify({ items: cartItems }),
});If you must retry a non-idempotent request, use an idempotency key: a unique token the server uses to recognize duplicate requests.
async function createOrderWithRetry(orderData) {
const idempotencyKey = crypto.randomUUID();
return fetchWithRetry("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(orderData),
});
}The server checks the idempotency key. If it has already processed a request with that key, it returns the stored result instead of creating a duplicate. This pattern requires server support, so it only works with APIs that document and implement idempotency keys on the specific endpoints that need them, typically ones dealing with payments or order creation where duplicates carry real cost.
Common retry mistakes
Retrying every error. A 401 error will never succeed on retry, since the credentials are wrong or missing and no amount of waiting fixes that. Filter by status code and error type instead of treating every failure the same way.
Not logging retries. Silent retries hide infrastructure problems, since a flaky dependency that always succeeds by the second attempt looks perfectly healthy from the outside even though it is degrading. Log each retry with the attempt number and delay so you can monitor failure patterns over time.
console.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
console.warn(`Retrying in ${delay}ms...`);Using the same delay for every retry. Fixed delays synchronize retries across clients and give the server no relief, since every client that failed at roughly the same moment ends up retrying at roughly the same moment too. Use exponential backoff at minimum, and add jitter on top of it whenever many clients might be affected by the same outage.
Retrying without a maximum. Infinite retries can loop forever if a dependency stays down, silently consuming resources and never surfacing the failure to anyone. Always set a limit: retry count, time budget, or both.
Forgetting that retries multiply server load. Three retries means up to 4x the original request volume during an outage. If every client does this, the retries themselves can make the outage worse.
Jitter and backoff mitigate this, but the best approach is to understand the tradeoff before adding retries to a struggling system.
For a complete error categorization system that decides which errors trigger retries, see Handling Async Errors with Try Catch in JS Guide. For circuit breaker and fallback patterns, see Advanced API Error Handling in JS: Full Guide.
Rune AI
Key Insights
- Only retry transient errors: network failures, 429, and 5xx status codes. Fail fast on 4xx client errors.
- Exponential backoff doubles the delay between retries to give the server time to recover.
- Jitter adds randomness to the delay to prevent thundering herds.
- Always set a maximum number of retries and a total time budget.
- Respect the server's Retry-After header when the API provides one.
Frequently Asked Questions
Which HTTP status codes should I retry?
How many retries should I attempt?
What is the difference between backoff and jitter?
Conclusion
A good retry strategy retries only transient errors, increases the wait between attempts, adds randomness to spread load, and eventually gives up. The simplest pattern you can trust is exponential backoff with jitter: double the delay each time, add some randomness, and stop after three to five attempts.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.