Skip to content

Kata: retry with exponential backoff and jitter

A retry kata is judged at its edges: how many calls, where the waits fall, which error escapes, and whether tests can fake time.

Predict first

Before you read: predict

ExamHall's autosave client sends each draft through its retry helper:

In a unit test the fake API answers every PUT with 503, and sleep is a fake that writes down each wait it is asked for and returns at once.

When retry gives up, what do the written-down waits add up to?

Code for this question
await retry(() => putAnswer(draft), {  retries: 4, baseMs: 200, factor: 2, maxMs: 500, jitter: 'none',  shouldRetry: (err) => err.status === 503,  sleep,});
Pick one

The 30-second version

retry(fn, options) calls fn at most 1 + retries times, awaiting each attempt inside try. On a failure it decides first: attempts spent, or shouldRetry saying no, means rethrow that error unchanged, with no wait. Otherwise it sleeps min(maxMs, baseMs × factor^i), scaled by random() for full jitter, and tries again. Inject sleep and random to test it.

A retry kata is judged at its edges: how many calls, where the waits fall, which error escapes, and whether tests can fake time.

Step through the code

Trace of the opening question

Play options

Ready: press Play or step with Next frame

Row 1 of 5 · Call 1 (i = 0)

Starting row · computed by comparing each row with the one before

  • Call: 1 (i = 0)
  • Answer: 503
  • Decision: 0 < 4, and 503 is retried
  • Wait written down: min(500, 200) = 200

Rule · step 4

Decide, then sleep. In catch, stop when attempts are spent or shouldRetry(err) is false. Stopping sleeps nothing, so the last failure gets no wait.

Rule · step 6

The wait. Before retry i, wait min(maxMs, baseMs × factor^i). Since i starts at 0, the first wait is the base (ds-retries-backoff-jitter step 3).

Call
1 (i = 0)
Answer
503
Decision
0 < 4, and 503 is retried
Wait written down
min(500, 200) = 200
Step
4, 6
Trace of the opening question
CallAnswerDecisionWait written downStep
1 (i = 0)5030 < 4, and 503 is retriedmin(500, 200) = 2004, 6
2 (i = 1) (changed)503retry (changed)min(500, 400) = 400 (changed)4, 6
3 (i = 2) (changed)503retrymin(500, 800) = 500 (changed)4, 6
4 (i = 3) (changed)503retrymin(500, 1600) = 500 (changed)4, 6
5 (i = 4) (changed)5034 ≥ 4: rethrow this 503 (changed)none (changed)2, 4, 5

Space plays or pauses · ←/→ one row · Home/End first/last

Rows come from the pack's own trace table.

The probe code (rows are not linked to code lines)
The probe code
await retry(() => putAnswer(draft), {  retries: 4, baseMs: 200, factor: 2, maxMs: 500, jitter: 'none',  shouldRetry: (err) => err.status === 503,  sleep,});

How it works

Step 1 of 10

Contract first. retry(fn, { retries, baseMs, factor, maxMs, jitter, shouldRetry, sleep, random }) resolves with fn's value or rejects with an error.
See it run

Not in this run

Analogy: A salvage diver freeing a fouled anchor, by her dive club's repeat-dive card

On the dive boat In the code
the card allows the first dive and three repeats fn runs at most 1 + retries times (step 2)
after a failed dive she rests on deck, each rest twice the last but never past the card's limit min(maxMs, baseMs × factor^i) (step 6)
after the last failed dive she heads for harbour at once no wait after the last failure (step 4)
"anchor wedged under rock" ends the job at the first look shouldRetry false: rethrow at once (step 4)
the job sheet records what the last dive found, in her words rethrow the last error unchanged (step 5)
each job starts its own count on its own sheet state per call (step 8)
the instructor checks a new diver's card with a stopwatch she can skip forward and a die she sets injected sleep and random (step 9)

In real terms: a correct retry makes at most 1 + retries calls, sleeps only between them, stops at once on a permanent error, and rejects with the error that stopped it.

Breaks down when: a diver can see the anchor's state before she dives; code learns only by trying. And the club's doubling rule is the analogy's own, not dive-table practice.

Contrasting case: a diver who dives once and goes home. That is a plain await fn(): no waits, and the first failure reaches the caller.

Examples, run for real

4.1 ExamHall EXH-2800: autosave gives up on the first 503 (MR !3100)

At 09:00 a burst of 503s from the answers API made ExamHall's autosave show "Not saved" while the API recovered within a second. MR !3100 sends each save through the reference retry, with shouldRetry true only for 503. Every autosave carries a sequence number and an Idempotency-Key, which is what makes repeating it safe (api-idempotency-keys).

export async function retry(fn, {
  retries = 3, baseMs = 100, factor = 2, maxMs = 10_000, jitter = 'full',
  shouldRetry = () => true, sleep, random = Math.random,
} = {}) {
  for (let attempt = 0; ; attempt += 1) {
    try {
      return await fn(attempt);
    } catch (error) {
      if (attempt >= retries || !shouldRetry(error)) throw error;
      const capped = Math.min(maxMs, baseMs * factor ** attempt);
      await sleep(jitter === 'full' ? capped * random() : capped);
    }
  }
}

The same outage replayed on a virtual clock, with random stubbed:

4.1 MR !3100 retry(putAnswer, { retries: 5, baseMs: 250, factor: 2, maxMs: 4000, jitter: 'full', shouldRetry: 503 only }) · random() stub 0.8, 0.4, 0.6
4.1 call 1 at 0 ms: 503 · wait 200 (0.8 × 250)
4.1 call 2 at 200 ms: 503 · wait 200 (0.4 × 500)
4.1 call 3 at 400 ms: 503 · wait 600 (0.6 × 1000)
4.1 call 4 at 1000 ms: 200 saved · no more waits
4.1 resolved saved after 4 calls · waits written down 200 + 200 + 600 = 1000 ms

The waits sit between calls only, each the capped backoff times that wait's own draw (steps 4, 6, 7). After the success nothing waits.

4.2 Proof domain, ticketing: the test list as a mutation check

A box office's seat-hold client uses the same retry. Its test list (step 10, lib/cases.mjs) is run against the reference and against four one-line mutants of it:

4.2 the test list T1, T2, T3, T4, T5, T6, T7 on the reference: passes 7 of 7
4.2 mutant await removed        (return await fn(attempt); -> return fn(attempt);): killed by T2, T3, T5, T6, T7
4.2 mutant shouldRetry dropped  (|| !shouldRetry(error) -> nothing): killed by T4
4.2 mutant cap dropped          (Math.min(maxMs, baseMs * factor ** attempt) -> baseMs * factor ** attempt): killed by T5, T6
4.2 mutant exponent dropped     (baseMs * factor ** attempt -> baseMs): killed by T2, T3, T5, T6, T7

Each mutant is killed by at least one test: removing await lets rejections escape the catch (step 3), dropping shouldRetry retries a seat already taken (step 4), and dropping the cap or the exponent changes the waits (step 6). A test list earns its place by killing mutants like these.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A box office's seat-hold client has its own retry(holdSeat, options). Its maxAttempts option counts every call, the first one included, and its loop counts calls from 1:

maxAttempts, shouldRetry and sleep come from the options, and wait(n) returns Math.min(maxMs, baseMs * factor ** (n - 1)), the wait after the n-th failed call. Four teammates each wrote the rest of the catch body.

Which one gives a correct retry?

Code for this question
for (let attempt = 1; ; attempt += 1) {  try {    return await holdSeat(attempt);  } catch (err) {    log.push(err);    // the rest of the catch body goes here  }}
Pick one

Question 2 of 7

A chat client builds its message sender once, at start-up:

Its tests all pass. Each candidate test below builds its own sender with retries: 3, baseMs: 100, factor: 2, maxMs: 1000, shouldRetry: (err) => err.status === 503 and a fake sleep.

Which test fails on this code and passes on a correct sender?

Code for this question
export function createSender({ retries, baseMs, factor, maxMs, shouldRetry, sleep }) {  let attempt = 0;  return async function send(post) {    for (; ; attempt += 1) {      try {        return await post(attempt);      } catch (err) {        if (attempt >= retries || !shouldRetry(err)) throw err;        await sleep(Math.min(maxMs, baseMs * factor ** attempt));      }    }  };}
Pick one

Question 3 of 7

A video player fetches each segment through retry with retries: 3, baseMs: 100, factor: 2, maxMs: 300, jitter: 'full'. Its wait line is await sleep(Math.min(maxMs, baseMs * factor ** attempt) * random());. A mutant changes it to await sleep(Math.min(maxMs, baseMs * factor ** attempt * random()));. Each test below fakes a fetch that fails every time and a sleep that writes down its waits.

Which test fails on the mutant and passes on the correct line?

Pick one

Question 4 of 7

A catalog page loads a product's price through the reference retry:

The price service answers the first call with 503, the second with 502, the third with 503 and the fourth with the price 12.99.

What does the await give the page?

Code for this question
const price = await retry(() => priceApi.get(sku), {  retries: 3, baseMs: 100, factor: 2, maxMs: 1000, jitter: 'none',  shouldRetry: (err) => err.status === 503,  sleep,});
Pick one

Question 5 of 7

Time box: 25 minutes. Say your test list out loud first. Then, in an empty file, write retry(fn, options) with the options retries, baseMs, factor, maxMs, jitter ('full' or 'none'), shouldRetry, sleep and random. Run the kata's tests against your file.

Which tests pass?

Question 6 of 7

A seat-reservation client ends its retry loop like this:

It runs with retries: 3 and shouldRetry: (err) => err.status === 503. The reservation page picks its message from the rejected error's status: 409 "Seat taken", 503 "Busy, try again", anything else "Something went wrong". Buyers whose seat was already taken now read "Something went wrong".

Which throw line makes the page right for a taken seat and for a long outage?

Code for this question
} catch (err) {  if (attempt >= retries || !shouldRetry(err)) {    throw new Error(`reserve failed after ${attempt + 1} attempt(s)`);  }  await sleep(Math.min(maxMs, baseMs * factor ** attempt));}
Pick one

Question 7 of 7

A ledger client reads an account's balance through this retry:

In a test with the default options, the balance service answers 503 every time. The read rejects after fn has run 3 times, and the fake sleep wrote down 200 and 400.

Which line holds the bug?

Code for this question
export async function retry(fn, { retries = 3, baseMs = 100, factor = 2, maxMs = 2000, shouldRetry = () => true, sleep }) {  let attempt = 0;  while (true) {    try {      return await fn(attempt);    } catch (err) {      attempt += 1;      if (attempt >= retries || !shouldRetry(err)) throw err;      await sleep(Math.min(maxMs, baseMs * factor ** attempt));    }  }}
Pick one

Go deeper

Pro tips
  1. Inject sleep and random as options, and assert exact waits. The curriculum row's tip: with both injected the kata is testable without real time. When: writing the test list (step 9). Verify (RAN, Node 24.19.0): node --test retry.test.mjs runs all seven tests with the fake sleep; T3 checks the waits [100, 200, 400] and the run takes no real wait.
Source

No item is keyed on a source; every key is executed. Valid as of 2026-10-04.

Checked against the tools and docs as of 2026-10-04.