Lesson 3 · Milestone: Retries

Retries, backoff and jitter

Lesson 2 taught you when to stop waiting. Now you try again. Done carelessly, retries turn a short outage into a long one, so this lesson is about trying again gently.

By the end you'll be able to decide which failures to retry, and spread retries out so a recovering server isn't knocked over again. You'll write src/retry.ts in the lab: a backoff formula, a retry budget and the retry loop that Lesson 6 builds on.

retrySending the same request again after a failure, hoping a later attempt succeeds.
backoffWaiting longer before each new attempt: 10 ms, then 20, then 40…
jitterRandomness added to the wait, so clients don't all retry at the same moment.
retry budgetA cap on how many retries a client may make, relative to its successes.

Read this first. Retrying a timeout is only safe if the operation is idempotent: doing it twice has the same effect as doing it once. A timeout is an unknown outcome (Lesson 1), so the first attempt may already have worked. That's how the naive policy in Lesson 1 charged 22 of 200 customers twice. This lesson is about how to retry. Whether it's safe to retry a charge is Lesson 4's job. Until then, retry only reads, or calls you know are idempotent.

What a retry can fix

A retry sends the identical request again. So ask: could the identical request succeed a moment later? Marc Brooker of AWS says client errors "should not be retried with the same request because they aren't going to succeed later"[1]. The lab's error classes, in lib/errors.ts, already tell you which is which:

ErrorWhat happenedRetry?
TimeoutErroryou stopped waiting; unknown outcomeyes, but only if the operation is idempotent
TransientError · 503refused before any work (overloaded)yes, after a wait
ValidationError · 400the request itself is wrongno: it will fail the same way

Two more error types arrive in Lesson 4, with idempotency keys. One of them, ConflictError (409, "a request with this key is still in flight"), is also worth retrying after a wait, so include it in your isRetryable now; npm run test:03 checks it.

Retries are selfish

Brooker again: "when a client retries, it spends more of the server's time to get a higher chance of success"[1]. When failures are rare and random, that's a good trade. When the server is failing because it's overloaded, every retry adds load to the thing that's already drowning. And if several layers each retry on their own, the attempts multiply. His advice: "our best practice is to retry at a single point in the stack"[1].

ATTEMPTS THAT REACH EACH LAYER WHEN EVERY LAYER TRIES 3× user API service database 3 9 27 n layers, k attempts each → kⁿ attempts at the bottom fix: retry at ONE layer; the others fail fast
Retry amplification: three layers, three attempts each, 27 attempts at the database for one click. The reference sheet has Brooker's and the SRE book's bigger examples.

The thundering herd

Picture the moment an outage ends. Hundreds of clients have been failing, and they all try again now. npm run l3 simulates exactly this: 500 clients retry against a server that can serve 50 requests per 10 ms tick, and each 503 it sends burns a tenth of the work of serving a request. Rejections cost real servers work too; the SRE book walks through a backend only 1% over capacity that retries push into crashing[2]. The simulation runs in virtual time with seed 1, so the numbers are reproducible:

  policy                total calls   wasted (503)   gave up   ms until done
  immediate                    4722           4648       426             100
  exponential                  4722           4648       426            4560
  full-jitter (yours)          2274           1774         0             260

  Requests arriving per tick (first 40 ticks):
  immediate            █████████▇
  exponential          ██ █   █       █               █
  full-jitter (yours)  ██▄▅▂▃▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▁ ▁

"Immediate" finishes in 100 ms only because 426 clients have used up their 10 attempts and given up by then. That isn't a win.

Why do "immediate" and "exponential" lose exactly the same 426? This simulation has no randomness except the jitter you add: all 500 clients fail on the same tick, latency never varies, and the server starts each tick fresh. So without jitter every wave is the same crowd hitting the same server, with the same result. Backoff only moves the waves further apart in time. That makes these two rows a worst case. Real networks add a little natural spread, so plain backoff does somewhat better than this, but the clusters remain. Note also that the collapse depends on rejections costing work: change the knobs in the challenge and the ranking can change.

Mental model: a crowd at a door that just reopened. If everyone pushes again immediately, the door jams again. If everyone agrees to wait exactly 10 seconds, they all push again together, ten seconds later. Only if each person waits a random time do they trickle through. The model breaks down in one place: jitter doesn't reduce the number of people. If demand stays above capacity for good, no retry policy helps. You need fewer retries (a budget, below) or fewer requests.

Watch the three policies on the same recovery. Press ▶, use the chapters, or turn on 🔇 narration. The bars are the lab's real per-tick arrivals.

Now switch between them yourself. Red bars are ticks where more requests arrive than the server can serve.

Capped exponential backoff, with full jitter

Backoff doubles the wait after each failed attempt, so a struggling server gets more breathing room. A cap stops the wait from growing forever. But backoff alone still synchronises clients: they failed together, so they wait the same time and come back together. Brooker's simulations show that with backoff alone "there are still clusters of calls", and he concludes: "The no-jitter exponential backoff approach is the clear loser"[3].

His fix is full jitter: compute the capped ceiling, then wait a random time between zero and it. Jittered backoff, he writes, "should be considered a standard approach for remote clients"[3]. Full jitter isn't the only jittered option: among the variants he compares, "Equal Jitter" is "the loser", while the choice between "Decorrelated Jitter" and "Full Jitter" "is less clear". Full jitter is the simplest one that works well, so it's the one you'll build. The Google SRE book agrees: "Always use randomized exponential backoff when scheduling retries"[2].

WAIT AFTER A FAILED ATTEMPT · base 10 ms (cap 2000 ms not reached yet) 0320 ms attempt 0 attempt 1 attempt 2 attempt 3 attempt 4 attempt 5 box = [0, ceiling]; the ceiling doubles ● = the actual wait, random in the box
Each box is the range of possible waits; the dot is one random draw. Two clients that failed together almost never pick the same dot. (The dots are illustrative.)

Here is the whole formula, the first thing you'll write in src/retry.ts:

// attempt = 0 before the first retry
ceiling = min(capMs, baseMs × 2^attempt)       1
wait    = random() × ceiling                    2
  1. Exponential, capped. 10, 20, 40, 80 ms… until the cap. Doubling gives a struggling server more room each time; the cap stops a client sleeping for minutes.
  2. Full jitter. Any point from 0 to the ceiling, uniformly. Take random as a parameter, so tests and the simulation can make it deterministic.

A retry budget

Backoff and jitter spread retries out in time, but they don't limit how many there are. The lab's RetryBudget is a token bucket, like the one Brooker's team uses[1]: it starts with 10 tokens, each retry spends one, and each success earns back 0.1. That allows about one retry per ten successes: plenty for rare blips, and almost nothing when the server is down, which is exactly when retries do the most harm. The tests pin down the details a budget gets wrong most often:

RuleWhy
Spend a token only on an error you would actually retryA 400 isn't a retry, so it mustn't drain the budget.
A retry needs a whole token (≥ 1)Half a token is not a retry; otherwise the bucket goes negative.
Every success refills, including first-try successesThe budget measures health; healthy traffic earns retries.
Never refill above the capacityAfter a calm hour, an uncapped bucket allows a retry storm.
Beware float driftIn JavaScript, 0.1 added ten times is 0.9999999999999999, not 1. Round when you add, or count in whole tenths.

The retry loop

Your retry(work, options) calls work(attempt) until it succeeds. After a failure, it checks four things in this order, and rethrows the error at the first one that says stop:

catch (e):
  ① out of attempts?          attempt + 1 ≥ maxAttempts         → throw e
  ② can a retry fix it?       !isRetryable(e)                   → throw e
  ③ does the budget allow it? budget && !budget.tryRetry()      → throw e
  ④ is there time to wait?    wait = backoffDelay(attempt)
                              wait ≥ deadline.remaining()       → throw e   (Lesson 2)
  sleep(wait), then try again
on success: budget.onSuccess(), return the value

Why this order? ① and ② are free and touch no shared state. ③ spends a token, so it comes after the free checks; if ④ then refuses, that one token is lost, which is acceptable this close to the deadline. ④ uses the deadline from Lesson 2: if the jittered wait would cross it, there's no point sleeping just to fail afterwards. Sleep the same wait you checked, and rethrow the last error itself (the same object, not wrapped in a new Error), so the caller can still tell a timeout from a 503. Lesson 6 relies on that. Run npm run test:03; it's red until all four pieces work.


Practice: from memory

No scrolling up.

A charge call to PayCo times out. Should your retry loop resend it?

The user, the API and the service each make up to 3 attempts at the next hop. How many attempts can one click cause at the database?

Why does exponential backoff without jitter still overload a recovering server?

Full jitter, base 10 ms, cap 100 ms, attempt = 5. How long does the client wait?

A call fails with ValidationError (400). What should retry() do?

Sort the failures

Match the sparklines

Fill the blanks: the backoff formula

function backoffDelay(attempt: number, o: BackoffOptions, random: () => number): number {
  const ceiling = Math.(o.capMs,
    o.baseMs *  ** attempt);
  return  * ceiling;
}
// base 10, cap 1000, attempt 4:
// the wait is between 0 and  ms

Hints: the cap is an upper limit. Each attempt doubles the ceiling. Full jitter uses the whole range from zero.

🏆 Milestone challenge: Retries

Implement src/retry.ts, then use the simulation to make and test a prediction.

cd labs/payflow
npm run test:03                          # red until src/retry.ts is done
npm run l3                               # default world
cp results/03-retry-storm.json results/03-default.json   # the next run overwrites it
CAPACITY=100 npm run l3                  # or CLIENTS=…, or REJECT_COST=… (pick ONE knob)
  1. Make it green. Implement backoffDelay, isRetryable, RetryBudget and retry until npm run test:03 passes. Don't open solutions/ first.
  2. Explain, predict, run. In 3–4 sentences, explain the three rows of the default run: why "immediate" collapses, why "exponential" loses the same 426 clients just later, and why full jitter loses none with fewer calls. Then pick one knob (CAPACITY, CLIENTS or REJECT_COST) and a new value, and before running, write down whether jitter's advantage (gave up, total calls) will grow or shrink, and why. Run it and compare.
  3. Transfer it. Take an API you use (Stripe, GitHub, OpenAI, your own) and its list of HTTP error codes. Classify six of them as retry / don't retry, with one line of reasoning each. Mark any that are retryable only if the request is idempotent.

Send me back: the npm run test:03 output, both results files, your explanation, your prediction and what actually happened, and the classification table.

Pass criteria (check yourself before sending):

When I've reviewed it, I'll write a learning record and unlock Retries.

Read or watch next

Primary source: Exponential Backoff And Jitter, Marc Brooker, AWS Architecture Blog. Short, with the simulations behind full jitter. Compare his charts with the lab's sparklines.

No high-trust video on retries and jitter could be verified, so this lesson's primary source is an article.

💬 Unsure whether a specific error from your API is safe to retry, or how big your retry budget should be? Ask me in the terminal. Paste the API's error docs and we'll classify them together.