← Back to question bank
JS FunctionMidMedium#2041 · 30m

Implement retry with abort and jitter

Build retry with abort and jitter. The interviewer expects a small, reusable utility with clear behavior under repeated calls and invalid inputs.

Answer Strategy

Retry is one of the most-overused patterns in frontend code. The senior framing is what NOT to retry: idempotent reads, yes; mutations without idempotency keys, no; 4xx responses, almost never; network-layer transient failures, with a budget. State the policy before the algorithm.

Separate the loop from the policy. The loop owns "fire, wait, fire again" with backoff and an abort signal. The policy owns "is this attempt worth retrying" via a shouldRetry predicate. Backoff uses full jitter (random in [0, ceiling]) — synchronized retries from many clients otherwise become a self-DoS during recovery.

Volunteer the dangerous cases. Without abort propagation a route change leaks an in-flight retry storm. Without a max delay an exponential backoff drifts into multi-minute waits the user never sees. Without a maximum attempt count a flapping endpoint pins the loop forever. Make each of those a parameter, not a hidden default.

Reference Implementation: Retry With Abort, Jitter, And Policy Hook

Async retry helper with full jitter exponential backoff, abort propagation, and a caller-owned should-retry policy. The wrapped task receives the signal and the attempt number.

type RetryOptions = {
  attempts: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
  // Abort signal lets the caller cancel between attempts. The wrapped task is
  // responsible for honoring its own signal during work; this only stops the
  // retry loop itself from waiting and re-firing.
  signal?: AbortSignal;
  // Allow the caller to decide whether an error is retryable. Default treats
  // every thrown value as retryable; production code typically excludes 4xx.
  shouldRetry?: (error: unknown, attempt: number) => boolean;
  // Inject jitter for tests. Default uses Math.random in [0, 1).
  jitter?: () => number;
};

class AbortedError extends Error {
  constructor() {
    super('Retry aborted');
    this.name = 'AbortedError';
  }
}

async function retry<T>(
  task: (signal: AbortSignal | undefined, attempt: number) => Promise<T>,
  options: RetryOptions
): Promise<T> {
  const {
    attempts,
    baseDelayMs = 100,
    maxDelayMs = 30_000,
    signal,
    shouldRetry = () => true,
    jitter = Math.random,
  } = options;

  if (attempts < 1) throw new Error('attempts must be >= 1');
  if (signal?.aborted) throw new AbortedError();

  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await task(signal, attempt);
    } catch (error) {
      lastError = error;
      const isLast = attempt === attempts;
      if (isLast || !shouldRetry(error, attempt) || signal?.aborted) {
        if (signal?.aborted) throw new AbortedError();
        throw error;
      }

      // Exponential backoff with full jitter (Marc Brooker's "AWS Backoff").
      // Spreads retries so a thundering herd does not synchronize.
      const ceiling= Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
      const delay= jitter() * ceiling;

      await sleep(delay, signal);
    }
  }

  throw lastError;
}

function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new AbortedError());
      return;
    }
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    function onAbort() {
      clearTimeout(timer);
      reject(new AbortedError());
    }
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

Runnable Playground

Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.


type RetryOptions = {
  attempts: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
  // Abort signal lets the caller cancel between attempts. The wrapped task is
  // responsible for honoring its own signal during work; this only stops the
  // retry loop itself from waiting and re-firing.
  signal?: AbortSignal;
  // Allow the caller to decide whether an error is retryable. Default treats
  // every thrown value as retryable; production code typically excludes 4xx.
  shouldRetry?: (error: unknown, attempt: number) => boolean;
  // Inject jitter for tests. Default uses Math.random in [0, 1).
  jitter?: () => number;
};

class AbortedError extends Error {
  constructor() {
    super('Retry aborted');
    this.name = 'AbortedError';
  }
}

async function retry<T>(
  task: (signal: AbortSignal | undefined, attempt: number) => Promise<T>,
  options: RetryOptions
): Promise<T> {
  const {
    attempts,
    baseDelayMs = 100,
    maxDelayMs = 30_000,
    signal,
    shouldRetry = () => true,
    jitter = Math.random,
  } = options;

  if (attempts < 1) throw new Error('attempts must be >= 1');
  if (signal?.aborted) throw new AbortedError();

  let lastError: unknown;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await task(signal, attempt);
    } catch (error) {
      lastError = error;
      const isLast = attempt === attempts;
      if (isLast || !shouldRetry(error, attempt) || signal?.aborted) {
        if (signal?.aborted) throw new AbortedError();
        throw error;
      }

      // Exponential backoff with full jitter (Marc Brooker's "AWS Backoff").
      // Spreads retries so a thundering herd does not synchronize.
      const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
      const delay = jitter() * ceiling;

      await sleep(delay, signal);
    }
  }

  throw lastError;
}

function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new AbortedError());
      return;
    }
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    function onAbort() {
      clearTimeout(timer);
      reject(new AbortedError());
    }
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}
TypeScript · runnable

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Loop
Test success on the first attempt, success after N flaps, exhaustion after exceeding the attempt count, and that shouldRetry can short-circuit.
Timing
Use fake timers or inject jitter() so the test is deterministic. Assert the delay sequence respects baseDelay, maxDelay, and the jitter contract.
Cancellation
Abort mid-wait and mid-task. Assert the loop rejects with AbortedError and stops firing the task.
import { describe, it, expect, vi } from 'vitest';

describe('retry', () => {
  it('resolves on the first attempt when the task succeeds', async () => {
    const task = vi.fn().mockResolvedValue('ok');
    const result = await retry(task, { attempts: 3, baseDelayMs: 0, jitter: () => 0 });
    expect(result).toBe('ok');
    expect(task).toHaveBeenCalledTimes(1);
  });

  it('retries transient failures and succeeds before exhausting attempts', async () => {
    let count = 0;
    const result = await retry(
      async () => {
        count += 1;
        if (count < 3) throw new Error('flap');
        return 'ok';
      },
      { attempts: 5, baseDelayMs: 0, jitter: () => 0 }
    );
    expect(result).toBe('ok');
    expect(count).toBe(3);
  });

  it('respects shouldRetry to short-circuit non-retryable errors', async () => {
    const task = vi.fn(async () => { throw new Error('fatal'); });
    await expect(
      retry(task, {
        attempts: 5,
        baseDelayMs: 0,
        jitter: () => 0,
        shouldRetry: () => false,
      })
    ).rejects.toThrow('fatal');
    expect(task).toHaveBeenCalledTimes(1);
  });

  it('aborts mid-loop when the signal fires', async () => {
    const controller = new AbortController();
    const task = vi.fn(async () => {
      controller.abort();
      throw new Error('temp');
    });
    await expect(
      retry(task, { attempts: 5, baseDelayMs: 50, signal: controller.signal })
    ).rejects.toThrow('Retry aborted');
  });
});

Interviewer Signal

Tests whether you can turn a familiar utility into a precise contract instead of coding only the happy path.

Constraints

  • Define the function signature before coding.
  • Do not rely on global mutable state unless it is part of the returned closure.
  • Explain time and space cost for the common path.

Model Answer Shape

  • Write the smallest public contract first.
  • Cover empty input, repeated calls, thrown errors, and cleanup behavior.
  • Keep implementation readable enough to narrate under interview pressure.

Tradeoffs

  • A compact implementation is attractive, but explicit state names are easier to debug live.
  • Supporting every possible input can distract from the core contract; state the scope before coding.

Edge Cases

  • No arguments or undefined callbacks.
  • Synchronous throw inside the wrapped function.
  • Repeated calls before the previous result settles.

Testing And Proof

  • Happy path with representative inputs.
  • Boundary input and repeated invocation.
  • Cleanup or cancellation if timers or promises are involved.

Follow-Ups

  • How would you expose cancellation?
  • How would the API change for React usage?