JavaScript interview question
Write a retry helper with exponential backoff for a flaky async operation.
Short answer
Wrap the async function in a loop that awaits it, catches errors, decides whether the error is retryable, waits base * 2^attempt plus random jitter, caps the delay, and rethrows the last error after max attempts. Retry only idempotent, transient failures, and never use retries to hide real test flakiness.
Answer
A retry helper calls an async function, and if it fails with a transient error, waits an exponentially growing, jittered delay and tries again, up to a limit, then rethrows the last error. The design questions matter more than the loop: what is retryable, is the operation idempotent, how long can the total wait be, and how do we keep visibility of the failures we absorbed.
Implementation (TypeScript, Node 22 or browser)
type RetryOptions = {
retries?: number; // additional attempts after the first
baseMs?: number;
maxDelayMs?: number;
shouldRetry?: (err: unknown, attempt: number) => boolean;
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function retry<T>(
fn: (attempt: number) => Promise<T>,
{
retries = 3,
baseMs = 200,
maxDelayMs = 5_000,
shouldRetry = () => true,
onRetry,
}: RetryOptions = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastError = err;
if (attempt === retries || !shouldRetry(err, attempt)) break;
const exp = Math.min(maxDelayMs, baseMs * 2 ** attempt);
const delay = Math.random() * exp; // "full jitter"
onRetry?.(err, attempt + 1, delay);
await sleep(delay);
}
}
throw lastError;
}
Key details:
return await fn()insidetryis required;return fn()withoutawaitwould let a rejection escape thecatch.- Exponential backoff (
base * 2^attempt) gives a struggling service time to recover. The cap bounds the worst case. - Jitter spreads retries from many parallel workers so they do not all hit the server at the same instant. “Full jitter” (random between 0 and the exponential value) is a widely used strategy described in AWS’s architecture guidance.
Deciding what to retry
class HttpError extends Error {
constructor(public status: number, message: string) { super(message); }
}
const isTransient = (err: unknown) =>
err instanceof HttpError
? [408, 429, 502, 503, 504].includes(err.status)
: err instanceof TypeError; // fetch throws TypeError on network failure
const order = await retry(
async () => {
const res = await fetch(`${baseUrl}/orders/${id}`);
if (!res.ok) throw new HttpError(res.status, `GET order ${res.status}`);
return res.json();
},
{
shouldRetry: isTransient,
onRetry: (e, n, d) => console.warn(`retry ${n} in ${Math.round(d)}ms`, e),
},
);
Do not retry 400, 401, 403, 404 or validation errors: repeating will not change the answer. Respect a Retry-After header on 429 or 503 if present.
Idempotency matters: retrying a GET or PUT is safe; retrying a POST /payments after a timeout can charge twice unless the API supports idempotency keys.
Retries in tests: use them carefully
- Retrying test setup against a shared, slow environment is reasonable.
- Retrying an assertion until the system reaches a state is better expressed with your framework’s polling helpers, such as Playwright’s
expect.pollorexpect(...).toPass(). - Retrying whole failed tests (Playwright
retries, Jestjest.retryTimes) hides flakiness unless retried passes are reported and tracked. A test that passes on retry is still flaky. - Always log or report absorbed failures; a retry that hides an intermittent 503 may be hiding a real product bug.
What interviewers look for
- Correct async control flow:
awaitinsidetry, rethrow the last error. - Exponential backoff with cap and jitter, with reasons.
- Retryable versus non-retryable errors and idempotency.
- A clear stance that retries are not a fix for flaky tests.
A strong answer covers
- Loops with await and try/catch, rethrowing the last error after max attempts
- Exponential delay with a cap
- Adds jitter to avoid synchronised retries
- Retries only transient, retryable errors and idempotent operations
- Warns that retries must not hide flaky tests or product bugs