Distributed Systems
Distributed Systems: Retries, Backoff, and Idempotency
Retry logic only works safely when APIs are designed for idempotent behavior.
2026-05-20
Why retries are harder than they look
Retries look like an easy reliability win. A request fails, you try again, and often it succeeds. In a single process talking to a healthy dependency, that intuition holds. In a distributed system under stress, the same habit can make things worse.
Retries multiply traffic at the exact moment the other side is already struggling. They can create duplicate side effects if the first attempt actually succeeded and only the response was lost. And when many clients retry on the same schedule, they turn a brief blip into a sustained outage.
I used to treat retries as a client-side detail: catch the error, sleep a bit, call again. That worked until a dependency slowed down and every instance of my service piled on at once. The dependency recovered briefly, then got hit by a wall of retries and fell over again. The lesson stuck. Retry logic is only as safe as the limits around it and the assumptions the API makes about duplicate requests.
A practical baseline has three parts:
- Bounded retries with exponential backoff
- Jitter so clients do not wake up together
- Idempotent endpoints so a retry cannot quietly do the wrong thing twice
The rest of this note walks through each piece and why they belong together.
Bounded retries with exponential backoff
Unbounded retries are a liability. If a dependency is down for minutes, an unbounded loop keeps generating load, holding resources, and delaying failure signals to callers who might prefer a fast error and a fallback. Even "retry until success" inside a request path can turn a 200ms timeout into a multi-second stall that cascades through the rest of your stack.
Set a retry budget
Bounded retries mean you decide up front how many attempts you are willing to make, and you stop when that budget is spent. A common pattern is a small fixed count (for example three attempts total) plus a maximum elapsed time.
- The count covers short, transient failures.
- The time budget covers cases where each attempt hangs near the timeout and you cannot afford to keep waiting.
Space attempts with exponential backoff
Exponential backoff is how you space those attempts. After the first failure, wait a short base delay. After the next, wait roughly twice as long, then twice again, up to a cap.
The idea is simple:
- If the failure is a brief network glitch, a quick retry is enough.
- If the failure is overload, waiting longer gives the other side room to recover instead of hammering it at a fixed rate.
Only retry what can succeed later
A few details matter in practice. Retry only errors that are likely transient:
- Connection resets
- Timeouts
- HTTP
503or429responses when the server says "try later"
Do not blindly retry 400-level client errors that will fail the same way every time. Respect Retry-After when the server sends it. Keep per-attempt timeouts short enough that your total budget stays within what the caller can tolerate.
Avoid stacked retries
Also decide where retries live. If every layer retries (browser, API gateway, service A, service B), a single failure can explode into dozens of downstream calls. Prefer one clear owner for retries on a given hop, or shrink budgets as you go deeper so the product of all layers stays sane.
Fail loudly when the budget is spent
When retries finally give up, fail loudly. Emit a metric for exhausted budgets and log the last error with enough context to debug. Callers need to know the system tried and stopped, not that it is silently spinning.
Use jitter to avoid synchronized retry storms
Exponential backoff alone is not enough when many clients share the same clock and the same failure.
Imagine a dependency blips for two seconds. Thousands of clients fail at roughly the same moment. If they all wait 100ms, then 200ms, then 400ms, they all wake up together. The dependency sees quiet, then a synchronized stampede, then quiet, then another stampede. That pattern is a classic retry storm, and it can keep a recovering service unhealthy long after the original cause is gone.
Break lockstep with randomness
Jitter breaks the lockstep. Instead of sleeping for exactly base * 2^attempt, each client adds randomness.
- Full jitter: pick a random delay uniformly between zero and the current exponential ceiling.
- Equal jitter: take half the exponential delay as a fixed floor, then add a random amount up to the other half.
Either way, clients spread out in time, so the recovering service sees a smoother ramp of traffic instead of a square wave.
Watch for accidental synchronization
Jitter also helps when retries were not caused by the same outage. Cron jobs, queue consumers, and autoscaled pods often start work in batches. Without jitter, their retries can align with deployment waves and scheduled traffic. A little randomness in the client is cheap insurance against accidental synchronization.
Cap the delay window
You still want an upper bound on delay. Jitter should smear load within a window, not send some clients off to sleep for minutes while the user waits.
- Cap the backoff.
- Keep the total budget finite.
- Make the random range wide enough to matter at your concurrency level.
For a handful of clients, tiny jitter is fine. For thousands of concurrent callers, the spread needs to be large enough that peaks do not still look like a cliff.
Treat rate limits as a stronger signal
When you get a rate-limit response, back off more aggressively than you would for a single timeout. Rate limits mean the system is already protecting itself. Retrying at the same intensity fights that protection. Combine the server's guidance with your own jittered backoff so you rejoin traffic politely.
Design endpoints to be idempotent where possible
Backoff and jitter address when and how often you retry. Idempotency addresses what happens if the same logical operation runs more than once.
The lost-response problem
Networks lie in awkward ways. You send a "create payment" request. The server processes it, writes to the database, and starts sending the response. The connection drops before your client reads the body.
- From the client's point of view, the request failed.
- From the server's point of view, it succeeded.
A naive retry creates a second payment. That is not a theoretical edge case. It shows up whenever timeouts are tight and work is not cheap to undo.
What idempotency means
An idempotent operation can be repeated safely. Doing it once or three times leaves the system in the same intended state.
| Method | Typical behavior |
|---|---|
GET | Naturally idempotent |
PUT by ID | Usually idempotent (replace by ID) |
POST that always creates | Not idempotent unless you add a key or unique constraint |
Use idempotency keys for writes
A practical pattern for write APIs is to require a client-generated idempotency key on mutating requests.
- The client sends the same key on retries.
- The server stores the key with the result of the first successful processing.
- On a duplicate key, it returns the original result instead of performing the work again.
That turns "maybe it worked" into "exactly once from the client's perspective," even though the underlying transport is at-least-once.
Fall back to uniqueness when a full store is heavy
Where a full idempotency store feels heavy, uniqueness constraints and careful upserts get you part of the way there. Deduplicate by a business identifier (order ID, transfer ID, message ID) so a second insert fails cleanly and your handler can treat that as success of the original intent.
Be careful with partial failures in multi-step workflows. If step one commits and step two fails, a blind retry may need compensating logic or a state machine that can resume, not restart from scratch.
Side effects need the same care
Idempotency also changes how you think about side effects outside your database. Sending an email, charging a card, or publishing an event twice is often worse than doing it zero times.
If those effects are not themselves idempotent:
- Gate them behind the same dedupe key, or
- Only call out after you have durably recorded intent
Which option you pick depends on which failure mode you can live with. There is rarely a perfect answer, but there should be a deliberate one.
Document the retry contract
Finally, document the contract. Clients need to know:
- Which endpoints are safe to retry
- Which keys to send
- How long keys are retained
Without that clarity, teams either retry nothing and absorb every blip as user-visible failure, or retry everything and invent duplicate data when the network hiccups.
Putting the baseline together
Retries improve resilience when they are limited, spaced out, and applied to operations that can absorb duplicates. Skip any one of those and the feature starts working against you.
In day-to-day design reviews, I ask a short set of questions:
- What is the retry budget, in attempts and wall-clock time?
- Are we adding jitter, or will every pod wake up together?
- Which errors are retryable?
- Who owns retries on this path so we do not stack them?
- If this write runs twice, what breaks?
That baseline will not cover every edge case. It does turn retry logic from a hopeful catch block into a reliability tool you can reason about under load. Start there, measure exhausted budgets and duplicate-key hits in production, and tighten the knobs when metrics say the story is incomplete.