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.

JavaScriptintermediate
15 min read

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 TypeShould RetryReason
Network error (no response)YesTransient connectivity issue
HTTP 408 Request TimeoutYesServer was too slow
HTTP 429 Too Many RequestsYes (with Retry-After)Rate limit, temporary
HTTP 500 Internal Server ErrorYesTransient server issue
HTTP 502 Bad GatewayYesUpstream server issue
HTTP 503 Service UnavailableYesServer overloaded or in maintenance
HTTP 504 Gateway TimeoutYesUpstream timeout
HTTP 400 Bad RequestNoClient sent invalid data
HTTP 401 UnauthorizedNoInvalid credentials
HTTP 403 ForbiddenNoInsufficient permissions
HTTP 404 Not FoundNoResource does not exist
HTTP 409 ConflictNoBusiness logic conflict
HTTP 422 UnprocessableNoValidation failure

Basic Retry With Exponential Backoff

javascriptjavascript
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.

javascriptjavascript
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

StrategyFormulaSpreadBest For
Nonebase * 2^attemptZeroTesting only
Fullrandom(0, base * 2^attempt)MaximumHigh-contention APIs
Equalbase * 2^attempt / 2 + random(0, half)ModerateGeneral purpose
Decorrelatedrandom(base, prev * 3)ProgressiveAWS-recommended
javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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

javascriptjavascript
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

javascriptjavascript
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

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
RunePowered by Rune AI

Frequently Asked Questions

How many times should I retry?

3 retries is the industry standard for most APIs. More retries increase the chance of success but also increase latency and server load. For critical operations (payments), use 2-3. For background tasks, you can use 5+.

What is jitter and why does it matter?

Jitter adds randomness to retry delays. Without it, all clients that failed simultaneously retry simultaneously, creating a thundering herd that can crash the recovering server. Full jitter provides maximum spread.

Should I retry POST requests?

Only if the endpoint supports idempotency (via an `Idempotency-Key` header or inherent idempotency). Retrying non-idempotent POSTs can create duplicate records.

What is a retry budget?

global limit on total retries across all requests in a time window. It prevents retry storms where every failing request retries independently, multiplying load on an already struggling server. See [rate limiting in JavaScript complete tutorial](/tutorials/programming-languages/javascript/rate-limiting-in-javascript-complete-tutorial) for related patterns.

How do I test retry logic?

Use a mock server that returns failures for the first N requests then succeeds. Inject a fake `sleep` function to avoid real delays. Assert on the number of attempts, delay durations, and final result.

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.