Timeouts, Retries and Backoff

Pick timeouts from a measured latency distribution, retry with full jitter, and cap retries with a budget, not a count.

Syntaxsleep = random(0, min(cap, base * 2 ** attempt)) // exponential backoff with full jitter

A timeout converts an unbounded wait into a failure you can handle. Without one, a hung dependency does not return an error: it holds a connection and a worker until the tier is full of calls that will never finish.

Choosing the number

Take it from the dependency's measured latency distribution, never from an average. Slightly above p99 of the healthy case is a defensible start: the timeout then fires on a genuine anomaly rather than on normal variance. Then check the arithmetic upward, because per-hop timeouts add up and nobody adds them up. The better shape is one deadline set at the edge and propagated, as gRPC does with its grpc-timeout header, so each hop spends what is left rather than restarting the clock.

Backoff and jitter

Retry only what is safe to repeat and might plausibly succeed: a timeout, a 503, a 429. Never a 400. Space the attempts with exponential backoff and full jitter, the form AWS recommends: sleep = random(0, min(cap, base * 2 ** attempt)). Jitter is the part people leave out and the part that matters. Without it, every client that failed in the same millisecond retries in the same millisecond, and the dependency meets a synchronised wall of traffic at the exact moment it tries to recover.

Retry storms

Retries multiply down the stack: three tiers that each try three times send 27 requests for one user action, so a small error rate becomes a load spike exactly when the system is weakest. Cap it with a budget, not a count. Google's SRE book allows three attempts per request and retries only while retries stay under 10% of requests; the AWS SDKs use a 500-token retry quota, charging 14 tokens per transient retry and 5 per throttling retry under the 2026 retry behaviour, which you currently opt into with AWS_NEW_RETRIES_2026=true until it becomes the default. The behaviour still in effect by default is the earlier standard mode, which charges 5 tokens per retry and 10 for a timeout or connection error out of the same 500.

Example

Example · javascript
// A retry helper with the three things hand-rolled ones leave out:
// full jitter, a shared budget, and a deadline the retries must fit inside.

const CAP_MS = 20_000;       // AWS SDK standard mode caps backoff at 20s
const BASE_TRANSIENT = 50;   // 50ms base for connection errors and 5xx
const BASE_THROTTLE = 1_000; // 1000ms base for throttling: it needs longer

// Token bucket shared by every call through this client, after the AWS SDK
// standard retry quota. Retries cost tokens, successes pay them back, and an
// empty bucket means "stop retrying, this is not a blip".
class RetryBudget {
  constructor(capacity = 500) {
    this.capacity = capacity;
    this.tokens = capacity;
  }
  canSpend(cost) { return this.tokens >= cost; }
  spend(cost) { this.tokens -= cost; }
  credit(amount) { this.tokens = Math.min(this.capacity, this.tokens + amount); }
}

const fullJitter = (attempt, base) =>
  Math.random() * Math.min(CAP_MS, base * 2 ** attempt);

const isRetryable = (err) =>
  err.name === 'TimeoutError' ||
  err.code === 'ECONNRESET' ||
  err.code === 'ECONNREFUSED' ||
  err.status === 429 ||
  (err.status >= 500 && err.status <= 504);

// Retry-After is RFC 9110 section 10.2.3: either delta-seconds or an HTTP-date.
function retryAfterMs(header) {
  if (!header) return null;
  const seconds = Number(header);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
  const at = Date.parse(header);
  return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
}

async function callWithRetries(fn, { budget, deadline, maxAttempts = 3 }) {
  let lastErr;
  let spentOnLastRetry = 0;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const remaining = deadline - Date.now();
    if (remaining <= 0) throw lastErr ?? new Error('deadline exceeded');

    try {
      // Each attempt gets what is LEFT of the deadline, not a fresh timeout.
      const out = await fn(AbortSignal.timeout(remaining));
      // Success after a retry refunds that retry; a clean first try credits 1.
      budget.credit(spentOnLastRetry || 1);
      return out;
    } catch (err) {
      lastErr = err;
      if (!isRetryable(err)) throw err;              // a 400 never improves
      if (attempt === maxAttempts - 1) throw err;

      const cost = err.status === 429 ? 5 : 14;      // throttling costs less
      if (!budget.canSpend(cost)) throw err;         // budget gone: fail fast
      budget.spend(cost);
      spentOnLastRetry = cost;

      const base = err.status === 429 ? BASE_THROTTLE : BASE_TRANSIENT;
      // Obey the server if it said when to come back; otherwise back off.
      const waitMs = retryAfterMs(err.headers?.['retry-after']) ?? fullJitter(attempt, base);
      // Never sleep past the deadline: the caller has already given up.
      if (Date.now() + waitMs >= deadline) throw err;
      await new Promise((r) => setTimeout(r, waitMs));
    }
  }
  throw lastErr;
}

When to use it

  • Setting the HTTP client timeout for a third-party API whose latency histogram you just pulled.
  • Working out why a dependency recovered but stayed down: clients retrying in lockstep.
  • Fitting three serial internal calls inside a 400 ms end-to-end budget.

More examples

The timeout shorthand, and the longhand it replaces

Worth knowing in both forms because the long one is what you will meet in existing code and the short one is what you should write in new code. The practical difference is the forgotten clearTimeout in the long form: in Node a pending timer keeps the process alive, so a server that writes the longhand without the finally block holds every request's timer for its full duration. AbortSignal.any matters for the common real case, a request timeout that must also abort when the client disconnects.

Example · javascript
// SHORTHAND. AbortSignal.timeout(ms) -- Node v17.3.0 (backported 16.14.0),
// and in browsers. Aborts with a DOMException whose name is 'TimeoutError'.
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });

// LONGHAND. Exactly the same thing, and what you still write when you need
// a handle on the controller, or on runtimes older than the above.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
try {
  const res2 = await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timer); // the shorthand version does this for you; forgetting
                       // it keeps the event loop alive for the full 2s
}

// Combining a timeout with a caller's own cancellation.
// SHORTHAND: AbortSignal.any -- Node v20.3.0 (backported 18.17.0).
const signal = AbortSignal.any([req.signal, AbortSignal.timeout(2000)]);

// LONGHAND, for older runtimes: forward whichever fires first.
const merged = new AbortController();
const forward = () => merged.abort();
req.signal.addEventListener('abort', forward, { once: true });
const t = setTimeout(forward, 2000);

Propagating one deadline instead of nesting timeouts

Reach for this the first time a latency SLO is violated by a chain nobody timed end to end. Per-hop timeouts are additive and nobody adds them up; a propagated deadline makes the budget a single number that shrinks. The clause that surprises people is the fail-fast check: a service that notices only 3 ms remain should refuse immediately, because running the query and then having the caller abandon the answer spends capacity for nothing.

Example · javascript
// The edge sets ONE budget. Every hop passes on what is left of it, so a
// three-service chain cannot spend 3 x 250ms against a 300ms promise.
const DEADLINE_HEADER = 'x-deadline-ms'; // gRPC does this as 'grpc-timeout'

// Middleware at the edge, and at every service in the chain.
function deadlines(defaultBudgetMs) {
  return (req, res, next) => {
    const inherited = Number(req.get(DEADLINE_HEADER));
    // Honour an inbound budget, and never extend it: a downstream service
    // must not outlive the client that is waiting for it.
    const budget = Number.isFinite(inherited)
      ? Math.min(inherited, defaultBudgetMs)
      : defaultBudgetMs;
    req.deadline = Date.now() + budget;
    next();
  };
}

async function callDownstream(req, path, body) {
  const remaining = req.deadline - Date.now();
  // Fail fast rather than starting work that cannot finish in time. In a
  // deep call graph this is where most of the saved capacity comes from.
  if (remaining <= 5) {
    throw Object.assign(new Error('deadline exceeded'), { status: 504 });
  }
  return fetch(`http://internal${path}`, {
    method: 'POST',
    body: JSON.stringify(body),
    headers: {
      'content-type': 'application/json',
      [DEADLINE_HEADER]: String(remaining), // pass the remainder down
    },
    signal: AbortSignal.timeout(remaining),
  });
}

Reading a timeout off the histogram instead of guessing

This is the step people skip, and skipping it produces both of the classic bugs: a timeout under p99 that turns normal variance into errors, and a timeout of 30 seconds that is really no timeout at all. Note the last calculation. A timeout is not a bound on how long the call takes once retries exist, so the number you promise the caller has to be checked against attempts multiplied by timeout plus backoff, not against the timeout alone.

Example · javascript
// Timeouts are set from data. These are bucket counts straight out of a
// Prometheus-style histogram for one dependency over a quiet week.
const buckets = [
  { leMs: 10, count: 412_000 },
  { leMs: 25, count: 905_000 },
  { leMs: 50, count: 980_000 },
  { leMs: 100, count: 994_000 },
  { leMs: 250, count: 998_600 },
  { leMs: 500, count: 999_400 },
  { leMs: 1000, count: 999_900 },
  { leMs: Infinity, count: 1_000_000 },
];

// Cumulative buckets, so the quantile is the first bucket past the rank.
function quantile(q) {
  const target = buckets.at(-1).count * q;
  const hit = buckets.find((b) => b.count >= target);
  return hit.leMs;
}

console.log(quantile(0.5));   // 25ms   <- the average lives near here
console.log(quantile(0.99));  // 100ms
console.log(quantile(0.999)); // 500ms  <- rank 999,000 is past the 250ms bucket

// A timeout at the mean would abandon a third of healthy requests. Sit just
// above p99 so the timeout means "abnormal", not "slightly unlucky".
const timeoutMs = Math.ceil(quantile(0.99) * 1.5); // 150ms

// Then sanity-check the upper bound: with 2 retries and full jitter capped
// at 20s, worst-case wall time is the sum, which must fit the deadline.
const worstCase = timeoutMs * 3 + 50 + 100; // attempts + both backoff caps
console.log(worstCase);  // 600ms -- fine under a 1s deadline, not under 500ms

Discussion

  • Be the first to comment on this lesson.