Skip to content
SDETRole
SDETRole

JavaScript interview question

Write a retry helper with exponential backoff for a flaky async operation.

advanced 5+ yrs #retry#backoff#async#resilience#flaky-tests

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() inside try is required; return fn() without await would let a rejection escape the catch.
  • 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.poll or expect(...).toPass().
  • Retrying whole failed tests (Playwright retries, Jest jest.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: await inside try, 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
Practise answering under time pressure

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →