#Promises#Async

Fetch And Retry

Implement a retry function that handles transient failures by retrying async operations with optional delays.

By Pratik RaiMedium

Networks fail. Requests time out, servers return 503 during a deploy, and mobile connections drop mid-flight. A retry wrapper turns those transient failures into a brief delay instead of an error screen — but a naive retry loop makes outages worse rather than better, and can duplicate data in ways users notice.

What fetch does and does not reject on

The first thing to get right, because it shapes the whole implementation:

JSfile.javascript
1const response = await fetch('/api/thing'); // 500 — does NOT throw

fetch rejects only on network-level failure — DNS failure, connection refused, CORS rejection, abort. Any HTTP response, including 404 and 500, is a successful fetch with response.ok === false.

So a retry wrapper must check both: catch thrown errors, and inspect the status of responses that arrive.

Retry only what is safe to retry

Two independent questions decide this.

Is the status transient? Retry 408, 429, and 5xx. Do not retry 4xx client errors — a 400 will be 400 forever, and retrying a 401 three times is how accounts get locked.

Is the method idempotent? GET, HEAD, PUT and DELETE produce the same result whether applied once or five times. POST generally does not. Retrying a failed POST after a timeout risks charging a card twice — the first request may have succeeded with only the response lost.

Safe POST retries need an idempotency key: a client-generated unique ID sent with the request, which the server uses to recognise and ignore duplicates. Without server support, do not retry POST automatically.

Exponential backoff with jitter

Retrying immediately is worse than not retrying. A struggling server that just returned 503 gets hit again instantly by every client at once — this is the thundering herd, and it is how a brief blip becomes a sustained outage.

Backoff spaces attempts out exponentially: 1s, 2s, 4s, 8s. But pure exponential backoff still synchronises clients — everyone who failed at the same moment retries at the same moment. Jitter adds randomness to break up the convergence:

JSfile.javascript
1const base = initialDelay * 2 ** attempt; 2const delay = Math.random() * base; // full jitter

Full jitter — a random value between zero and the computed backoff — is what AWS recommends, and it consistently outperforms adding a small random offset to a fixed delay.

The implementation

JSfile.javascript
1const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); 2 3async function fetchWithRetry(url, options = {}, config = {}) { 4 const { 5 retries = 3, 6 initialDelay = 300, 7 maxDelay = 10_000, 8 timeout = 10_000, 9 } = config; 10 11 let lastError; 12 13 for (let attempt = 0; attempt <= retries; attempt++) { 14 const controller = new AbortController(); 15 const timer = setTimeout(() => controller.abort(), timeout); 16 17 try { 18 const response = await fetch(url, { ...options, signal: controller.signal }); 19 20 if (response.ok) return response; 21 22 if (!RETRYABLE_STATUS.has(response.status) || attempt === retries) { 23 return response; // permanent failure — hand it back, do not throw 24 } 25 26 // Honour Retry-After when the server sends it. 27 const retryAfter = Number(response.headers.get('Retry-After')); 28 lastError = new Error(`HTTP ${response.status}`); 29 await sleep( 30 Number.isFinite(retryAfter) && retryAfter > 0 31 ? retryAfter * 1000 32 : backoff(attempt, initialDelay, maxDelay) 33 ); 34 } catch (error) { 35 // A caller-initiated abort must not be retried. 36 if (options.signal?.aborted) throw error; 37 lastError = error; 38 if (attempt === retries) throw error; 39 await sleep(backoff(attempt, initialDelay, maxDelay)); 40 } finally { 41 clearTimeout(timer); 42 } 43 } 44 45 throw lastError; 46} 47 48const backoff = (attempt, initial, max) => 49 Math.random() * Math.min(max, initial * 2 ** attempt); 50 51const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

A few decisions worth calling out:

Retry-After takes precedence. When a server sends that header — standard with 429 and 503 — it is telling you exactly when to come back. Ignoring it in favour of your own backoff is rude and usually counterproductive.

Non-retryable responses are returned, not thrown. A 404 is a legitimate answer the caller should handle, not an exception.

Caller aborts are distinguished from timeouts. If the caller's own signal fired, the user navigated away or cancelled — retrying would be wrong. Only the internal timeout should trigger another attempt.

clearTimeout sits in finally so the timeout timer never outlives its attempt.

What retries cannot fix

Retrying makes transient failures invisible, which is the point — but it also hides real problems. An endpoint failing 40% of the time looks healthy from the outside if every request eventually succeeds on attempt two. Log retry counts and alert on the rate, or you will not discover the degradation until it gets worse.

Retries also multiply load exactly when a system is least able to handle it. Three retries per client means a struggling service receives four times the traffic. This is what circuit breakers exist for: after N consecutive failures, stop trying entirely for a cooling-off period rather than continuing to hammer a service that is already down.

Key takeaways

  • fetch does not reject on 4xx or 5xx — check response.ok as well as catching.
  • Retry 408, 429 and 5xx only; never retry a 4xx client error.
  • Only retry non-idempotent methods when the server supports idempotency keys.
  • Use exponential backoff with full jitter to avoid synchronised retry storms.
  • Honour Retry-After when present.
  • Distinguish caller aborts from timeouts, and never retry the former.
  • Log retry rates — silent retries hide degradation.

Goal: Implement a retry function that handles transient failures. Bonus: Add exponential backoff with jitter.

Frequently asked questions

What does a retry function need to get right beyond looping?
Knowing what is safe to retry. `GET`, `PUT` and `DELETE` are idempotent, so repeating them leaves the same state; a `POST` generally is not, and retrying it can create two orders. Retrying blindly is the answer that gets followed up on.
Should you retry every failure?
No. A `4xx` other than `429` means the request was wrong and will be wrong again — retrying wastes time and hides the bug. Retry on network failures, `5xx` and `429`, and respect a `Retry-After` header when the server sends one.
What is exponential backoff, and why jitter?
Backoff multiplies the delay each attempt — 100ms, 200ms, 400ms — so a struggling server is not hit at a constant rate. Jitter adds randomness, which matters because without it every client that failed at the same moment retries at the same moment, and the retry storm keeps the server down. Mentioning jitter unprompted is a strong signal.
What are the follow-ups?
Recursive versus loop-based implementations, cancelling an in-flight retry chain with `AbortController`, and a circuit breaker that stops attempting once a service has failed repeatedly.

Related Challenges

Continue learning with these related challenges

View All
JavaScript

Promise.all Polyfill

Implement your own Promise.all polyfill to understand how concurrent Promise handling works under the hood.

JavaScript · Promises · Async/AwaitPratik Rai ·

JavaScript

Promise Pool

Run a list of async tasks with a hard limit on how many are in flight at once — the concurrency control Promise.all does not give you.

JavaScript · ES6Pratik Rai ·

JavaScript

Promise Time Limit

Wrap an async function so it gives up after a deadline — the building block behind every request timeout.

JavaScript · ES6Pratik Rai ·