Module A-6·28 min read

Retry storms as self-inflicted DDoS, exponential backoff with jitter, retry budgets, and deadline propagation across a call graph.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module A-6 — Timeouts, Retries, and Backoff

What this module covers: Why the absence of a timeout is a design decision to fail, and how an unbounded wait converts someone else's slowdown into your outage through Little's Law. The four timeouts you need and the one that actually bounds anything, plus the per-attempt arithmetic that quietly exceeds your caller's patience. Which failures are safe to retry and which are the ambiguous failure wearing a status code. Backoff with full jitter, and why jitter is not a refinement. Then the two ideas that matter most: retry amplification, where three retries at four layers becomes eighty-one times the load on the deepest service exactly when it can least take it, and the metastable failure state in which retries sustain an overload after its trigger is gone. Retry budgets as the mechanism that bounds amplification regardless of error rate — the single most valuable thing in this module and the one most systems lack. Then deadline propagation, without which an overloaded system spends its recovery capacity on work nobody is waiting for, cancellation that actually cancels, and hedged requests as a deliberate purchase of tail latency with load.


No Timeout Is Not "Wait Patiently," It Is "Fail Together"

Most client libraries default to no overall timeout. fetch in Node has no total deadline by default. Many database drivers wait indefinitely. That default is not neutral — it is a decision to couple your availability to your slowest dependency, and Little's Law explains why (Module F-4):

concurrency = arrival rate × service time

A dependency's p99 goes from 50 ms to 30 seconds. Your arrival rate has not changed. So your concurrency rises by a factor of 600, and every one of those in-flight requests is holding a socket, a connection from the pool (Module P-4), a request slot, and some memory. At 200 requests/second with a 30-second service time, you need 6,000 concurrent slots. You do not have 6,000. So:

text

Nothing failed. A dependency got slow, and the absence of a bound propagated it. A timeout is how you convert someone else's latency problem into your error rate — which sounds like a downgrade and is not, because an error is bounded, attributable, and recoverable, while unbounded latency spreads.

Analogy: a queue at a single service desk where one customer's transaction is taking forty minutes. With no policy, the twenty people behind them wait forty minutes, then the twenty behind them leave, and the shop's throughput collapses over one difficult case. With a policy — "we will spend at most five minutes per customer, then you must take a ticket and come back" — one customer is inconvenienced and the queue keeps moving. Notice also the failure of a badly chosen policy: a two-minute limit means most legitimate transactions are cut off and everyone re-queues, which is more total work than serving them would have been. That is the retry-amplification section in miniature.


Four Timeouts, and Only One of Them Bounds You

A single number labelled "timeout" is usually not the number you think:

TimeoutBoundsTypical valueFailure it catches
ConnectTCP handshake (plus TLS)1–3 shost down, no route, exhausted SYN backlog
Time to first byteserver thinking timedependency's p99.9slow query, saturated server
Idle / socketgap between bytesa few secondsa stream that stalls mid-response
Total / overallthe whole operationyour budgeteverything, including retries

Only the last one actually bounds your resource usage. A connect timeout of 2 s and a read timeout of 5 s do not add up to a guarantee, because a response that trickles a byte every 4 seconds never trips either one. Set the overall deadline, always, and treat the others as diagnostics that let you fail earlier with a clearer reason.

How to choose the number. Not from the average — from the p99.9 of the successful response distribution, plus headroom. Two failure directions bound the choice:

  • Too short: you abandon requests that would have succeeded, convert them into retries, and thereby add load to a dependency that was merely slow. A timeout below the dependency's real p99 is a load amplifier disguised as a safety measure.
  • Too long: you hold resources through a failure you could have detected, which is the scenario above.

And the arithmetic that catches everyone:

text

A retry policy must fit inside the overall budget, which means the per-attempt timeout is remaining_budget / max_attempts, not a number chosen independently. This is also the setup for deadline propagation below.


Retry Only What Is Safe, and a Status Code Is Not Proof

Module P-16 established the ambiguous failure: a caller that received no response has learned nothing about whether the work happened. Retry policy is that principle applied per failure type.

FailureSafe to retry?Why
Connection refused / DNS failureyesthe request never reached the server; nothing happened
503, 502 with no bodyyesthe server is declaring itself unable
429yes — honour Retry-Afterthe server is telling you when (Module P-18)
500only if idempotentthe handler may have committed before failing
Timeoutonly if idempotentthe canonical ambiguous failure — it may have succeeded
400, 422, 404nodeterministic; identical bytes fail identically
409no, usuallya conflict needs a decision, not a repeat
401 / 403no (retry once after refreshing a token, then stop)credentials will not improve by repetition

Two things people get wrong here.

HTTP method semantics are a hint, not a guarantee. GET, PUT and DELETE are specified as idempotent, and plenty of real handlers are not — a GET that increments a view counter, a PUT that appends. Conversely a POST carrying an idempotency key is perfectly safe to retry. Base the decision on what the operation does and whether it carries a key, not on the verb.

Retrying a non-idempotent 500 or timeout is the double-charge bug. If you cannot make the operation idempotent, the correct policy is do not retry and surface the ambiguity — which for money means the unknown state and the sweeper from Module P-24.


Backoff With Full Jitter, Because Synchronised Clients Rebuild the Spike

Fixed-interval retries are the wrong answer for a specific reason: a thousand clients that failed at the same moment retry at the same moment.

text

Exponential backoff alone fixes the frequency and not the synchronisation. Jitter fixes the synchronisation, and it is not a refinement — without it, a recovering dependency is knocked over by the first aligned burst, which restarts the cycle. The standard form is full jitter:

ts

Three details that matter: cap the exponential, or attempt 12 waits nine hours; bound the number of attempts, because unbounded retries are indistinguishable from an attack; and make the first retry immediate or near-immediate if the failure was a connection refusal, since that costs the dependency nothing and fixes the common case of hitting an instance that is draining (Module A-5).

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.