← Back to question bank
JS FunctionSeniorHard#222 · 35mFinance specialization

Implement abortable retry with exponential backoff

Write a typed helper that handles cancellation, retryable errors, jitter, and cleanup on unmount.

Answer Strategy

For abortable retry with exponential backoff, start by stating the public contract before writing code: argument shape, return shape, mutation rules, error behavior, and whether work is synchronous, timed, cached, or cancellable.

A senior solution uses boring names for hidden state. If the function stores a timer, cache entry, listener, or in-flight promise, say who owns that state and how it is cleaned up.

After the baseline passes, harden the edge cases: empty input, repeated calls, invalid values, thrown callbacks, stable ordering, and memory lifetime. The reference below is written to be narrated line by line.

Reference Implementation: Retry With Abort And Jitter

Retry code should be cancellable and should avoid synchronized retry storms through jittered delay.

type RetryOptions = {
  attempts: number;
  baseDelayMs: number;
  signal?: AbortSignal;
  jitter?: (attempt: number) => number;
};

function delay(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException('Aborted', 'AbortError'));
      return;
    }
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true });
  });
}

async function retry<T>(task: () => Promise<T>, options: RetryOptions): Promise<T> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= options.attempts; attempt = 1) {
    try {
      return await task();
    } catch (error) {
      lastError= error;
      if (attempt= options.attempts) break;
      const jitter= options.jitter?.(attempt) ?? 0;
      await delay(options.baseDelayMs * attempt + jitter, options.signal);
    }
  }

  throw lastError;
}

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;
  signal?: AbortSignal;
  jitter?: (attempt: number) => number;
};

function delay(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    if (signal?.aborted) {
      reject(new DOMException('Aborted', 'AbortError'));
      return;
    }
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true });
  });
}

async function retry<T>(task: () => Promise<T>, options: RetryOptions): Promise<T> {
  let lastError: unknown;

  for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
    try {
      return await task();
    } catch (error) {
      lastError = error;
      if (attempt === options.attempts) break;
      const jitter = options.jitter?.(attempt) ?? 0;
      await delay(options.baseDelayMs * attempt + jitter, options.signal);
    }
  }

  throw lastError;
}
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.

Contract
Test the public signature and return value first, including whether the utility mutates input or stores closure state.
Edges
Add empty input, repeated calls, duplicate values, thrown callbacks, and cleanup or cancellation cases.
Timing
Use fake timers for timer utilities and controlled promises for async utilities so behavior is deterministic.
test('retry resolves after transient failures', async () => {
  let attempts = 0;
  const result = await retry(
    async () => {
      attempts += 1;
      if (attempts < 3) throw new Error('temporary');
      return 'ok';
    },
    { attempts: 3, baseDelayMs: 0, jitter: () => 0 }
  );

  expect(result).toBe('ok');
  expect(attempts).toBe(3);
});

Interviewer Signal

Use it for polling a transfer detail endpoint or refreshing quote data.

Constraints

  • Keep local, backend, wallet, chain, and user-visible state distinct.
  • Name the product risk before naming the component.
  • Tie the answer back to testing or rollout safety.

Model Answer Shape

  • Write a typed helper that handles cancellation, retryable errors, jitter, and cleanup on unmount.
  • Use explicit ownership boundaries for state, data, and user intent.
  • Describe how the UI prevents misleading certainty during pending or failed operations.

Tradeoffs

  • Finance-grade UI should be conservative about certainty and optimistic about continuity.
  • Local state improves recovery but must not pretend to be canonical business truth.

Edge Cases

  • Refresh during pending work.
  • Duplicate user intent.
  • Backend, wallet, and chain disagree temporarily.

Testing And Proof

  • State transition test.
  • Reload recovery scenario.
  • Accessible status and copy review.

Follow-Ups

  • What would you log for support?
  • How would you roll this out behind a flag?

Deep Finance Practice

This item has an authored finance specialization page with the original prompt, solution, and any available runnable harness.

Open legacy practice #222 ->