Promise.all Polyfill
Implement your own Promise.all polyfill to understand how concurrent Promise handling works under the hood.
JavaScript · Promises · Async/Await — Pratik Rai ·
Implement a retry function that handles transient failures by retrying async operations with optional delays.
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.
fetch does and does not reject onThe first thing to get right, because it shapes the whole implementation:
JSfile.javascript1const 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.
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.
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.javascript1const 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.
JSfile.javascript1const 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.
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.
fetch does not reject on 4xx or 5xx — check response.ok as well as catching.Retry-After when present.Goal: Implement a retry function that handles transient failures. Bonus: Add exponential backoff with jitter.
Continue learning with these related challenges
Implement your own Promise.all polyfill to understand how concurrent Promise handling works under the hood.
JavaScript · Promises · Async/Await — Pratik Rai ·
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 · ES6 — Pratik Rai ·
Wrap an async function so it gives up after a deadline — the building block behind every request timeout.
JavaScript · ES6 — Pratik Rai ·
Implement your own Promise.all polyfill to understand how concurrent Promise handling works under the hood.
JavaScript · Promises · Async/Await
Pratik Rai ·
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 · ES6
Pratik Rai ·
Wrap an async function so it gives up after a deadline — the building block behind every request timeout.
JavaScript · ES6
Pratik Rai ·