Retry a Promise N Times
Retry an async function with a bounded count, delay, and a shouldRetry predicate. Do not retry 4xx you caused, and never hide the last error.
- javascript
- promises
- async
- interview
retry(fetch(url), 3) is already wrong. fetch(url) started once. A fulfilled or rejected promise stays settled. Calling retry on that object cannot “try again.” The helper has to take a factory: a function that returns a new promise each attempt.
The second classic miss is retrying a 400 you caused, or wrapping the last rejection in new Error('retry failed') so the caller loses status, body, and stack. Bounded attempts, a delay, a shouldRetry predicate, and rethrowing the same last error are the whole API.
Key takeaways
taskis a function you call per attempt. Never retry an already-built Promise.attemptsis a hard cap. After the last failure, throw that failure, not a wrapper withoutcause.shouldRetry(error, attempt)decides 429/5xx vs 4xx. Default-retry-everything is how you hammer a bad request.- Optional
AbortSignalcancels the wait and should be passed intotasksofetchcan abort too.
Attempts, backoff, and a reason to stop
Name the count. This implementation uses attempts as total tries (first call + retries). attempts: 3 means at most three calls, not “three retries after a first success path.”
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
const id = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function isAbortError(err) {
return err?.name === 'AbortError' || err?.name === 'TimeoutError';
}
function defaultShouldRetry(err) {
if (isAbortError(err)) return false;
const status = err?.status ?? err?.response?.status;
if (status == null) return true; // network / TypeError from fetch
if (status === 429) return true;
if (status >= 500) return true;
return false; // 4xx you caused, 2xx should not be here
}
async function retry(task, options = {}) {
const {
attempts = 3,
delay = 0,
backoff = 2,
shouldRetry = defaultShouldRetry,
signal,
} = options;
if (typeof task !== 'function') {
throw new TypeError('retry: task must be a function that returns a promise');
}
if (!Number.isInteger(attempts) || attempts < 1) {
throw new RangeError('retry: attempts must be an integer >= 1');
}
let waitMs = delay;
let lastError;
for (let i = 0; i < attempts; i++) {
if (signal?.aborted) {
throw signal.reason ?? new DOMException('Aborted', 'AbortError');
}
try {
return await task({ attempt: i + 1, signal });
} catch (err) {
lastError = err;
const hasMore = i < attempts - 1;
if (!hasMore || !shouldRetry(err, i + 1)) {
throw err;
}
if (waitMs > 0) {
await wait(waitMs, signal);
waitMs *= backoff;
}
}
}
throw lastError;
}
const data = await retry(
({ signal }) => fetch('/api/unreliable', { signal }).then(async (res) => {
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}),
{ attempts: 4, delay: 200, backoff: 2 },
);
task({ attempt, signal }) returns a new promise every loop. await adopts thenables (Promises).
If the interviewer says “retry N times,” ask whether N is total attempts or retries after the first failure. This helper treats attempts: 3 as three calls. retries: 2 would be the same idea with a different name. Mixing them without saying which is how off-by-one bugs show up in production dashboards (“we retried 3 times” meaning 4 HTTP calls).
What not to retry
| Failure | Retry? |
|---|---|
Network drop, TypeError: Failed to fetch |
yes |
| HTTP 502 / 503 / 504 | yes |
| HTTP 429 | yes, preferably with delay |
| HTTP 400 / 401 / 403 / 404 | no |
AbortError from the caller’s signal |
no |
Programmer TypeError in task |
usually no; shouldRetry can filter by name |
fetch does not throw on 404. If you forget if (!res.ok) throw, retry never sees an HTTP error. That is a Fetch bug, not a retry bug.
Do not retry a mutation blindly (POST creating orders). Idempotent GET/PUT with an idempotency key is the safe default. Say that when the prompt is “retry the save.” If you do retry a POST, send the same idempotency key on every attempt so the server can collapse duplicates.
Never hide the last error
// wrong
throw new Error(`Failed after ${attempts} attempts`);
// acceptable if you must wrap
throw new Error(`Failed after ${attempts} attempts`, { cause: lastError });
// best
throw lastError;
Callers branch on err.status, err.name, and instanceof. A string wrapper forces message parsing. If you wrap, Error cause keeps the original.
The unreachable throw lastError after the loop is a belt for type checkers. Every path already threw or returned.
Delay, backoff, jitter
Constant delay is fine for a whiteboard. Production adds:
- Exponential backoff:
waitMs *= backoff(the sketch). - Cap:
Math.min(waitMs, maxDelay). - Jitter:
waitMs * (0.5 + Math.random())so a fleet does not retry in lockstep.
Sleep must observe signal, or abort waits until the timer fires. clearTimeout in the abort listener is the same leak-avoidance idea as Timeout a Promise.
Abort
Pass the same AbortSignal into fetch. Retrying after the user left the page is wasted work and can apply a late response if you ignore abort.
const controller = new AbortController();
button.addEventListener('click', () => controller.abort());
await retry(({ signal }) => fetch('/api/slow', { signal }), {
attempts: 3,
delay: 300,
signal: controller.signal,
});
AbortSignal.any([user.signal, AbortSignal.timeout(8000)]) combines user cancel and a deadline. See AbortController. Retry should treat abort as terminal (shouldRetry returns false).
Procedure
- Reject a non-function
task. Decideattempts(total tries, ≥ 1). - Loop: if aborted, throw. Call
task({ attempt, signal }). Return on success. - On failure: if last attempt or
!shouldRetry(err), throwerr. - Else wait (
delaywith backoff), observing the signal. - Never retry 4xx you caused. Never swallow the last error.
Interview out-loud answer
Retry takes a factory, not a Promise, because a settled promise cannot run again. I cap total attempts, wait with optional exponential backoff, and consult
shouldRetryso 400s do not loop. The last rejection is rethrown as-is. AnAbortSignalcancels both the sleep and the in-flightfetchiftaskforwards it. I would not retry non-idempotent POSTs without an idempotency key.
Related on this site
- Promises: settlement is one-shot, thenables,
await - AbortController: cancel in-flight work
- Timeout a Promise: deadline vs retry
- Promise.all Polyfill: do not confuse fan-out with replay
- Fetch API Fundamentals:
okvs throw - Run Async Tasks in Series: sequential attempts without replay policy
Further reading
Related guides
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.