Skip to content

Retry and Timeout

A remote call is not like a local function call. It travels over a network that drops packets, through load balancers that occasionally reset connections, to a service that might be in the middle of a rolling deploy or briefly garbage-collecting. Many of these problems are transient — they last milliseconds and the very next attempt would succeed. A connection reset, a momentary 503 while a pod restarts, a brief network hiccup: none of these means the dependency is truly broken.

Two failure modes hide inside every remote call. The first is the unbounded wait: a call with no time limit can hang for the entire duration of a TCP timeout — minutes, on some defaults — while a thread sits idle holding a connection. The second is the transient blip: a single attempt fails for a reason that has already passed, and giving up entirely turns a recoverable hiccup into a user-visible error.

But naive fixes make things worse. Retrying immediately, without delay, hammers a recovering dependency. Retrying in lockstep across thousands of clients creates a thundering herd — a synchronized wave of retries that all arrive at the same instant and knock the dependency back over. And retrying a non-idempotent operation — “charge this card”, “ship this order” — can execute it twice.

So the forces are: you want to recover from blips automatically, you must never wait forever, you must not amplify load on a struggling dependency, and you must not duplicate side effects.

Combine two disciplines on every remote call.

Timeout. Put a hard upper bound on how long a single attempt may take. When the budget elapses, the attempt is cancelled and counted as a failure. This caps the latency a caller can ever experience and frees the resource immediately.

Retry with exponential backoff and jitter. On a transient failure, try again — but wait longer between each attempt (the delay grows exponentially: roughly base, base × 2, base × 4, …) and add a random jitter to each delay so concurrent clients spread out instead of retrying in unison. Cap the number of attempts so failure is eventually surfaced, and only retry operations that are idempotent — running them twice has the same effect as running them once.

sequenceDiagram
  participant C as Caller
  participant D as Dependency
  C->>D: attempt 1 (timeout 1s)
  Note over C,D: no response within 1s
  C--xC: timeout fires, attempt cancelled
  Note over C: wait ~0.5s (base + jitter)
  C->>D: attempt 2 (timeout 1s)
  D--xC: 503 transient
  Note over C: wait ~1.0s (base*2 + jitter)
  C->>D: attempt 3 (timeout 1s)
  D-->>C: 200 OK
Timeout bounds each attempt; retries wait progressively longer with jitter until one succeeds or the budget runs out

Here is a helper that runs an operation with a per-attempt timeout and retries transient failures using exponential backoff plus jitter. It assumes the operation is idempotent. Each example is self-contained and idiomatic to its language.

interface RetryOptions {
attempts: number;
timeoutMs: number;
baseDelayMs: number;
isTransient: (err: unknown) => boolean;
}
function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
return fn(ctrl.signal).finally(() => clearTimeout(timer));
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function retry<T>(
op: (signal: AbortSignal) => Promise<T>,
opts: RetryOptions,
): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < opts.attempts; i++) {
try {
return await withTimeout(op, opts.timeoutMs);
} catch (err) {
lastErr = err;
if (!opts.isTransient(err) || i === opts.attempts - 1) throw err;
// Exponential backoff with full jitter.
const ceiling = opts.baseDelayMs * 2 ** i;
await sleep(Math.random() * ceiling);
}
}
throw lastErr;
}

What you gain:

  • Bounded latency. Because every attempt has a timeout, no call hangs forever, and a caller knows the worst-case time it can spend (roughly attempts times the timeout plus the backoff delays).
  • Automatic recovery from blips. Transient resets, brief 503s, and momentary network hiccups heal themselves without surfacing an error to the user.
  • Spread-out load. Jitter breaks the synchronization that creates thundering herds, so a recovering dependency is not knocked over by a wall of simultaneous retries.

What it costs you:

  • Risk of retry storms. Retries multiply load. If a dependency is genuinely overloaded — not just blipping — naive retries add fuel to the fire. This is exactly why retry must be paired with a circuit breaker: once the breaker trips, retries stop entirely until the dependency recovers.
  • Idempotency is mandatory. Retrying a non-idempotent operation can execute its side effect twice. Either restrict retries to safe operations (reads, idempotent writes) or make writes idempotent with an idempotency key so a duplicate is detected and ignored.
  • Tuning the budget. Too many attempts or too long a timeout makes a failing call expensive for the caller; too few makes the retry pointless. The total time budget must fit inside the caller’s own deadline.
  • Circuit Breaker — the essential partner that stops retries from becoming a storm.
  • Bulkhead — bounds the resources retries can consume while they wait.
  • Saga — its compensating transactions must be idempotent precisely because they may be retried.
Why does every remote call need a hard timeout?
What problem does adding jitter to retry delays solve?
Which operations are safe to retry without extra care?
Why should retries be paired with a circuit breaker?