Batching Promises
intermediateProcess a large list of async tasks in fixed-size sequential batches to protect downstream services.
Implement an async retry wrapper in JavaScript with exponential backoff, full jitter, retryable-error filtering, and AbortSignal support — the way production clients do it.
Implement
retry(fn, options): call the async function; if it rejects, wait and try again, up to N attempts, with exponentially increasing delays.
The naive loop is five lines. What interviewers are actually probing is whether you've operated retries in production, because naive retry is actively harmful: it retries errors that can never succeed, and it synchronizes clients into thundering herds that finish off an already-struggling service. Every real client library (AWS SDKs, gRPC, fetch-retry) converges on the same three refinements — selective retry, exponential backoff, jitter — and this question checks whether you know why each exists.
async function retry(fn, {
retries = 3, // retry attempts AFTER the first try (4 calls total)
baseDelay = 200, // ms
maxDelay = 10_000, // cap — backoff must not grow unbounded
shouldRetry = () => true, // which errors are worth retrying
signal, // optional AbortSignal
} = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt); // pass attempt for logging/metrics
} catch (error) {
const outOfAttempts = attempt >= retries;
if (outOfAttempts || !shouldRetry(error) || signal?.aborted) {
throw error; // rethrow the LAST error, unchanged
}
// Exponential backoff with FULL JITTER:
// random point in [0, min(maxDelay, base * 2^attempt))
const cap = Math.min(maxDelay, baseDelay * 2 ** attempt);
const delay = Math.random() * cap;
await sleep(delay, signal);
}
}
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(id);
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}Because fn is called inside try within an async function, synchronous throws are captured too — the recursive setTimeout(attempt) formulations lose sync throws on retry attempts (the error escapes the timer callback and becomes an uncaught exception). The async/await loop gets this right for free; noting it is a strong detail.
const data = await retry(
() => fetch(url, { signal }).then((res) => {
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}),
{
retries: 3,
shouldRetry: (err) =>
err.name === "TypeError" || // network failure
err.status === 429 || // rate limited
(err.status >= 500 && err.status <= 599), // server errors
signal,
}
);Selective retry (shouldRetry) — a 404 will be a 404 all five times; a 400 means your request is malformed; a ReferenceError means your code is broken. Retrying them adds latency and masks bugs. The rule of thumb worth reciting: retry transient failures (network drop, 429, 5xx, timeouts), never deterministic ones (4xx except 429, programming errors). Bonus: an idempotency caveat — retrying a non-idempotent POST can double-charge; real systems pair retries with idempotency keys.
Exponential backoff — a failing service needs decreasing pressure, not constant-interval hammering. Doubling (200ms, 400ms, 800ms…) backs off fast while keeping early retries cheap. The maxDelay cap prevents attempt 10 from waiting 17 minutes.
Jitter — if 1,000 clients fail at the same instant (service blip), fixed backoff makes them all retry at the same instants forever — synchronized waves that keep re-killing the recovering service. Randomizing the delay decorrelates them. Full jitter (random(0, cap), used above) is the variant AWS's classic analysis found best; naming the thundering-herd problem it solves is the senior signal in this entire question.
retries: 3 means 3 retries, 4 calls total. Off-by-one here silently changes behavior; state your convention.AggregateError) — a fine extension if you say what you're doing.fn — handled by the try/await shape (see above); the classic recursive-setTimeout version crashes.signal support, unmounted components and abandoned requests keep retrying. The sleep must also be abortable — aborting mid-delay is the case everyone forgets. See AbortController.Retry-After headers — 429/503 responses often tell you how long to wait; honoring the server beats your own schedule. Great "productionize this" answer.setTimeout structure losing sync throws and complicating cancellation.new Error("retries exhausted") — destroys the stack and status code the caller needs for handling.Date.now() + budget) and bail when the next delay would cross it; deadlines compose better than counts across call stacks.withRetry(fn, opts) returning a wrapped function — same closure pattern as memoize.fn stub failing K times then succeeding, assertions on call counts and on delay distribution bounds (jitter makes exact-value asserts wrong — assert the range).Process a large list of async tasks in fixed-size sequential batches to protect downstream services.
Cancel fetch requests, add timeouts with AbortSignal.timeout, and write abortable async utilities.
Run async tasks one after another and understand why reduce-with-promises works.
Run N async tasks with at most K in flight — the production-style p-limit pattern, including ordering and failure trade-offs.