---
title: 'Retry and Backoff'
source: 'https://academia.sh/en/courses/frontend-architecture/retry-and-backoff'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Retry and Backoff

Which errors get retried automatically, the math of exponential backoff and jitter, the server's suggested wait, the retry budget, and a circuit breaker that stops sending the request after consecutive failures.

The previous lesson treated the `failed` event as a single gate: an error arrived, the
view moved to the error state, the user was shown a "try again" button. But some errors
should not be shown to the user at all. An observer connecting to the North Slope station
from the mountain has a connection that drops and recovers within seconds; a request
retried two hundred milliseconds later succeeds, and the user sees nothing in between.

This lesson's question is: which error gets retried automatically, how long to wait, how
many times to try, and when to give up. Answering all four wrong produces the same
outcome — an already-strained server gets strained further by its own clients.

## Which Error Gets Retried

The decision looks at two pieces of information: the error's class and the request's
method.

The error class was established in the REST Client lesson. In **HTTP errors**, the
server's decision is known: a 4xx is about the request itself, and repeating the same
request produces the same response. There are two exceptions — 429 reports a rate
limit, 5xx the server's current state; both can change over time.

In **network errors**, whether the server received the request is unknown. The response
may have been lost on the way back; in that case the server has already processed the
request. So for a network error, the decision falls to the method. The **safe** methods
defined in the How the Internet Works course do not change state, and repeating them is
harmless. A request that submits a measurement record, on the other hand, can produce
two records if processed twice; it cannot be retried automatically.

There is a way around this limit: an **idempotency key** generated by the client is
attached to the request, and the server does not count a second request with the same
key as a new record. Sending the key makes a state-changing request retryable too. This
is a contract the client and server must agree on together; it cannot be set up
unilaterally.

**Parse errors** and **cancellation** are never retried under any circumstance. The
first is the program's own bug, the second is already a decision to give up.

## Computing the Wait Duration

Retrying immediately does not help: the problem needs time to clear. A fixed interval
is not enough either; if the server cannot get back up, it keeps taking the same load at
the same frequency. **Exponential backoff** grows the wait duration by a multiplier on
every attempt; recovery is fast if the problem is short, and the client backs off if
it is long.

Exponential growth alone is unbounded, so a **cap** is set. The real missing piece is
something else. When a server goes down, every client connected to it gets an error at
the same moment; if they all apply the same formula, they all retry at the same moment.
The instant the server comes back up, it meets a synchronized wave and goes back down.
**Jitter** spreads out that wave by adding randomness to the wait duration.

```js
// backoff.mjs — wait durations and jitter's effect on distribution
// Deterministic pseudo-random generator: the same seed gives the same sequence.
function makeRng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const BASE = 200, MULTIPLIER = 2, CAP = 5000;
const exponential = (n) => Math.min(CAP, BASE * MULTIPLIER ** n);

const random = makeRng(20260114);
const fullJitter = (n) => Math.round(random() * exponential(n));

// Decorrelated jitter: the next wait is chosen from a range tied to the previous one.
let previous = BASE;
const decorrelatedJitter = () => {
  previous = Math.min(CAP, Math.round(BASE + random() * (previous * 3 - BASE)));
  return previous;
};

console.log("attempt  fixed   exponential  exp+cap  full jitter  decorrelated");
for (let n = 0; n < 6; n++) {
  console.log(
    String(n + 1).padEnd(9) +
    String(BASE).padEnd(8) +
    String(BASE * MULTIPLIER ** n).padEnd(13) +
    String(exponential(n)).padEnd(9) +
    String(fullJitter(n)).padEnd(13) +
    String(decorrelatedJitter()));
}

// --- Wake-up distribution of clients that dropped at the same time ----------
const CLIENTS = 24, ATTEMPT = 3;   // third attempt's wait: exponential(2) = 800 ms
const bucket = (ms) => Math.floor(ms / 200) * 200;

const count = (list) => {
  const m = new Map();
  for (const v of list) m.set(v, (m.get(v) ?? 0) + 1);
  return [...m.entries()].sort((a, b) => a[0] - b[0])
    .map(([k, s]) => `${String(k).padStart(4)}ms:${"#".repeat(s)}`);
};

const noJitter = Array.from({ length: CLIENTS }, () => bucket(exponential(ATTEMPT - 1)));
const rng2 = makeRng(20260114);
const withJitter = Array.from({ length: CLIENTS },
  () => bucket(Math.round(rng2() * exponential(ATTEMPT - 1))));

console.log("\nno jitter (24 clients, 3rd attempt):");
count(noJitter).forEach((s) => console.log("  " + s));
console.log("with jitter (24 clients, 3rd attempt):");
count(withJitter).forEach((s) => console.log("  " + s));
```

```
attempt  fixed   exponential  exp+cap  full jitter  decorrelated
1        200     200          200      70           384
2        200     400          400      296          1034
3        200     800          800      780          2121
4        200     1600         1600     303          1933
5        200     3200         3200     480          3986
6        200     6400         5000     957          5000

no jitter (24 clients, 3rd attempt):
   800ms:########################
with jitter (24 clients, 3rd attempt):
     0ms:######
   200ms:#####
   400ms:#######
   600ms:######
```

The two histograms show the difference plainly. In the no-jitter calculation, all
twenty-four clients pile up on a single instant; in the jittered calculation, the same
clients spread across four separate intervals. The peak load the server sees drops to a
quarter.

The table's two jitter forms make different trade-offs. **Full jitter** picks the wait
from anywhere between zero and the exponential value — the widest spread, but some
attempts land very early. **Decorrelated jitter** picks the next wait from a range tied
to the previous one; growth is smoother and less jumpy, reaching the cap by the sixth
attempt in the table. The choice depends on the service's recovery time: full jitter
suits a fast-recovering service, decorrelated jitter a slow-recovering one.

Note that the randomness is built with a deterministic generator. In production the
generator is unseeded; here the seed is held fixed so the output is the same on every
run and the table can be verified.

## Building the Loop

The calculation alone is not enough; when the loop stops and how much it honors what the
server says also need to be defined.

```js
// retry-loop.mjs — retry loop running against a local server
import http from "node:http";

let measurementRequests = 0;
const server = http.createServer((req, res) => {
  const path = new URL(req.url, "http://127.0.0.1").pathname;
  if (path === "/measurements" && req.method === "GET") {
    measurementRequests += 1;
    if (measurementRequests <= 2) {                // the first two requests fail
      res.writeHead(503, { "content-type": "application/json" });
      return res.end(JSON.stringify({ code: "unavailable" }));
    }
    res.writeHead(200, { "content-type": "application/json" });
    return res.end(JSON.stringify([{ id: "m-114", value: -4.2 }]));
  }
  if (path === "/measurements" && req.method === "POST") {
    res.writeHead(422, { "content-type": "application/json" });
    return res.end(JSON.stringify({ code: "validation" }));
  }
  if (path === "/report") {
    res.writeHead(429, { "content-type": "application/json", "retry-after": "2" });
    return res.end(JSON.stringify({ code: "rate_limit" }));
  }
  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ code: "no_route" }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Wait calculation (deterministic) ---------------------------------------
function makeRng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const random = makeRng(20260114);
const wait = (n) => Math.round(random() * Math.min(5000, 200 * 2 ** n));

// --- Policy: which outcome is retried ---------------------------------------
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function isRetryable(outcome, method) {
  if (outcome.type === "network") return SAFE_METHODS.has(method);  // unknown if it arrived
  if (outcome.type === "http") return outcome.status === 429 || outcome.status >= 500;
  return false;                                                     // parse error, cancellation
}

// --- Loop ---------------------------------------------------------------
async function retry(path, { method = "GET", maxAttempts = 4, sleep }) {
  const waits = [];
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let outcome;
    try {
      const response = await fetch(BASE + path, { method });
      const body = await response.json();
      outcome = response.ok
        ? { type: "success", data: body }
        : { type: "http", status: response.status,
            serverSuggestion: Number(response.headers.get("retry-after") ?? 0) * 1000 };
    } catch {
      outcome = { type: "network" };
    }
    if (outcome.type === "success") return { outcome, attempt: attempt + 1, waits };
    if (!isRetryable(outcome, method)) return { outcome, attempt: attempt + 1, waits };
    if (attempt === maxAttempts - 1) return { outcome, attempt: attempt + 1, waits };

    // If the server's suggestion is longer than the computed wait, the suggestion wins.
    const w = Math.max(wait(attempt), outcome.serverSuggestion ?? 0);
    waits.push(w);
    await sleep(w);
  }
}

// The test records durations instead of actually waiting; a real
// application would have a real timer here.
const recorded = [];
const fakeSleep = (ms) => { recorded.push(ms); return Promise.resolve(); };

const log = (name, r) => console.log(
  name.padEnd(24),
  `attempt=${r.attempt}`.padEnd(11),
  `outcome=${r.outcome.type}${r.outcome.status ? "/" + r.outcome.status : ""}`.padEnd(17),
  `waits=[${r.waits.join(", ")}]`);

log("503, then success", await retry("/measurements", { sleep: fakeSleep }));
log("422 (not retried)", await retry("/measurements", { method: "POST", sleep: fakeSleep }));
log("429 (Retry-After: 2)", await retry("/report", { maxAttempts: 3, sleep: fakeSleep }));
log("404 (not retried)", await retry("/missing", { sleep: fakeSleep }));

console.log("measurement requests reaching the server:", measurementRequests);
console.log("all recorded waits                      :", recorded.join(", "));

server.close();
```

```
503, then success        attempt=3   outcome=success   waits=[70, 184]
422 (not retried)        attempt=1   outcome=http/422  waits=[]
429 (Retry-After: 2)     attempt=3   outcome=http/429  waits=[2000, 2000]
404 (not retried)        attempt=1   outcome=http/404  waits=[]
measurement requests reaching the server: 3
all recorded waits                      : 70, 184, 2000, 2000
```

The first row shows the expected behavior: the server returned 503 twice, the loop
waited twice, and data arrived on the third attempt. The user saw none of this; the view
was still in the `loading` state.

The second and fourth rows show the policy's discriminating power. The validation error
and the not-found response both finished in a single attempt, with no wait at all, since
retrying would not change the outcome. The most common retry mistake is feeding every
error into the loop: when the user enters a bad value, finding out takes three times as
long.

The third row shows **the server's suggestion winning**. The computed wait was a few
hundred milliseconds; because the server said two seconds, two seconds were waited. The
server knows its own recovery time better than the client does, so its suggestion is
used not as a floor and not as a ceiling but as a **lower bound**. If the suggestion is
unreasonably long, giving up on the attempt — telling the user the situation — beats
waiting.

The loop has three limits. An **attempt count** ceiling prevents an infinite loop. A
**total budget** limits the combined duration of all attempts; a user pressing a button
and waiting eight seconds is worse than seeing an error. Third, **user-triggered**
requests and **background** requests have separate policies: attempt count is kept low
and duration short when a user is waiting, and the reverse holds for a background
refresh.

## Circuit Breaker

Retrying is a single request's problem. If the server is entirely unreachable, every
request runs its own attempts, and the application both keeps the user waiting and loads
the server with requests that will never succeed. A **circuit breaker** recognizes this
situation at a level above: once consecutive failures cross a threshold, it **stops
sending the request at all**.

```js
// circuit-breaker.mjs — not sending the request at all after consecutive failures
const THRESHOLD = 3, COOLDOWN = 5000;   // 3 consecutive failures; retry allowed after 5000 ms

function circuitBreaker() {
  let state = "closed", consecutiveFailures = 0, openedAt = 0;

  return {
    allow(now) {
      if (state === "open" && now - openedAt >= COOLDOWN) state = "halfOpen";
      return { allowed: state !== "open", state };
    },
    report(success, now) {
      if (success) { state = "closed"; consecutiveFailures = 0; return state; }
      consecutiveFailures += 1;
      if (state === "halfOpen" || consecutiveFailures >= THRESHOLD) {
        state = "open"; openedAt = now; consecutiveFailures = 0;
      }
      return state;
    },
  };
}

// Logical clock: advances step by step instead of real time.
const breaker = circuitBreaker();
const EVENTS = [
  [    0, false], [  200, false], [  600, false],   // the third failure opens the circuit
  [  900, false], [ 1500, false],                   // during cooldown: the request does not go out
  [ 6000, false],                                    // half-open attempt, fails
  [ 8000, false],                                    // back in cooldown
  [11500, true ],                                    // half-open attempt, succeeds
  [11800, true ],
];

let sent = 0, blocked = 0;
console.log("time    allow  prev state    result        new state");
for (const [now, success] of EVENTS) {
  const { allowed, state } = breaker.allow(now);
  if (!allowed) {
    blocked += 1;
    console.log(String(now).padEnd(8) + "no     " + state.padEnd(14) +
                "not sent".padEnd(13) + state);
    continue;
  }
  sent += 1;
  const next = breaker.report(success, now);
  console.log(String(now).padEnd(8) + "yes    " + state.padEnd(14) +
              (success ? "success" : "failure").padEnd(13) + next);
}
console.log(`sent: ${sent}  blocked: ${blocked}`);
```

```
time    allow  prev state    result        new state
0       yes    closed        failure      closed
200     yes    closed        failure      closed
600     yes    closed        failure      open
900     no     open          not sent     open
1500    no     open          not sent     open
6000    yes    halfOpen      failure      open
8000    no     open          not sent     open
11500   yes    halfOpen      success      closed
11800   yes    closed        success      closed
sent: 6  blocked: 3
```

The three states and their transitions can be read from the output. In the **closed**
state requests pass through; a third consecutive failure moves the circuit to **open**.
In the open state no request is sent — the call fails immediately and without touching
the network — three of the nine events never reached the server. Once the cooldown
elapses, the circuit becomes **half-open** and allows exactly one trial request. That
trial's outcome is decisive: on failure the circuit opens again and the cooldown
restarts (the behavior seen at the 6000 mark), on success it closes and the counter
resets.

While the circuit is open, the call site gets no response but does get an error. This
error should be kept distinct from a network error: the user is told "the service is not
responding right now," and since the "try again" button will not do anything until the
cooldown elapses anyway, reporting that duration is meaningful.

The circuit breaker's scope is also a design decision. A single breaker covering the
whole application lets one endpoint's failure stop every screen. In practice a breaker
is usually set up **per service or endpoint group**, so the station list stays readable
while the measurement-writing path is broken.

## Summary

- The retry decision looks at error class and method: 429 and 5xx are always retried, a
  network error only for safe methods; validation errors, parse errors, and
  cancellations are never retried.
- A state-changing request is retryable only if an idempotency key generated by the
  client has been agreed on with the server.
- Exponential backoff grows the wait on every attempt and is bounded by a cap; jitter
  keeps clients that failed at the same moment from coming back at the same moment.
- Full jitter has the widest spread, decorrelated jitter smooths the growth; the choice
  depends on the service's recovery time.
- The server's reported wait suggestion is compared with the computed duration and the
  larger one is applied; a total duration budget limits things alongside the attempt
  count.
- A circuit breaker stops sending the request at all after consecutive failures; it
  moves between open, half-open, and closed states and is set up per endpoint group.

## Next Step

The Data Access topic is complete here: data is fetched, stored, updated as a stream,
unexpected states are reduced to a finite set, and network instability is absorbed
before it reaches the user. All of this flow was one-directional — from server to
screen. The next topic sets up the reverse direction: data the user supplies. The
measurement entry form asks all at once where a field's value is held, when to validate
it, how to report an error, and what to show during submission. The first lesson opens
with the first of these questions: where does a field's value live — in the document, or
in the application's state?
