← Concepts & practices
Concept Errors, results, and recovery

Retry, backoff & idempotency

Try again safely, and give up on time.

A failed request may be worth one more try. Before it goes again, ask what can change, whether the receiver can recognize the same operation, and who owns the limit.

The idea to keepWait to reduce a retry burst; preserve one operation identity; never mistake waiting for safety.

TypeScriptGoRust One bounded retry decision in each language.

Definition

An idempotent operation has the same intended effect whether it runs once or several times. Only such an operation can be sent again after an unknown outcome; backoff and jitter decide when the next attempt goes, not whether it is safe.

After RFC 9110, HTTP Semantics, §9.2.2 Idempotent Methods

01A fair first retry

One small retry can be sensible.

A desktop creative tool sends a short diagnostic report to its studio's inbox. The first request gets a temporary unavailable response. If the same report can be safely recognized by the receiver, trying once more after a brief blip is a reasonable start.

The simple loop below gives one report up to eight attempts. For one cheap, repeat-safe request, that can be enough.

Read the starting loopOne request at a time
forwarder.ts
// The first version: one event, a brief blip, so try again straight away.
export async function forwardWithRetry(send: () => Promise<number>, attempts = 8): Promise<number> {
	let status = 0;
	for (let attempt = 1; attempt <= attempts; attempt++) {
		status = await send();
		// Stop on anything that isn't overload or an outage.
		if (status < 500 && status !== 429) return status;
	}
	return status;
}

The first loop treats 429 and 5xx as retryable and sends each attempt immediately. That status classification is an example caller policy. A 503 or a timeout does not by itself prove that a remote write did nothing.

02Spread a shared outage

Many careful loops can still arrive together.

Twelve clients meet one short outage. The table and counts below are derived from the lesson's forwarder and its fixed inputs; one tick is a logical second. They show this authored schedule, not a network benchmark.

Fixed schedule from the forwarder · all times are logical ticks
ScenarioAttemptsDeliveredStoppedPeak retries / tickTicks with sends
One report, one brief blip2101t0, t1
Twelve reports · immediate6012012t0–t5
Twelve reports · backoff4812012t0, t2, t6, t14, t22
Twelve reports · jitter391209t0–t6
Destination stays down860129t0–t14, t16–t23

Immediate retries use 60 attempts in this schedule. Exponential backoff uses 48, but equal failures still create shared retry times. Jitter draws different waits inside a capped window; this seeded schedule uses 39 and peaks at 9 retries in one tick. These totals depend on the batch size, capacity, outage, and seed shown above.

forwarder.ts
// When to try again: nothing between tries, a doubling wait, or a random point inside it.
export function retryDelay(
	policy: Policy,
	failedAttempts: number,
	random: number
): { delay: number; random: number } {
	const window = Math.min(8, 2 ** Math.min(failedAttempts, 3)); // 2, 4, 8, 8… ticks.
	if (policy === 'immediate') return { delay: 1, random }; // Next tick: no deliberate backoff.
	if (policy === 'backoff') return { delay: window, random };
	const next = (Math.imul(random, 1664525) + 1013904223) >>> 0;
	const unit = next / 4294967296;
	return { delay: Math.max(1, Math.ceil(unit * window)), random: next };
	// Full jitter rounded up to our one-tick clock. The first tick is the minimum.
}

03Preserve the operation

A timeout can leave the outcome unknown.

The receiver may have saved a report and lost its reply. A second request with a new operation ID can create a duplicate; waiting longer does not change that. Retry only when the receiver provides a duplicate-recognition contract, and send the same key and payload again.

The model keeps each job's key and original deadline fixed. The retry owner classifies the outcome, tracks attempts, selects the next due time, and records why it stops. The receiver owns the promise to recognize repeats; the model does not store a deduplication record.

forwarder.ts
// Whether to try again at all. Each check can end the event, and says why.
export function afterFailure(
	job: Job,
	config: Config,
	now: number,
	retryable: boolean,
	serverDelay = 0
) {
	if (!retryable) {
		stop(job, 'permanent');
		return;
	}
	if (!config.repeatSafe) {
		stop(job, 'unsafe');
		return;
	}
	if (job.attempts >= config.maxAttempts) {
		stop(job, 'attempt-limit');
		return;
	}
	const next = retryDelay(config.policy, job.attempts, job.random);
	job.random = next.random;
	const delay = Math.max(next.delay, serverDelay); // Never shorten the server's wait.
	if (now + delay >= config.deadline) {
		stop(job, 'deadline');
		return;
	}
	job.due = now + delay; // Keep the original key, payload, attempt count, and deadline.
}

Experiment

Send one report through a timeout

The first attempt times out after the inbox has already stored the report; the second finds it down. Every attempt sends the same key. Predict how many reports the inbox holds at the end, then step through the loop and watch now and the wait grow.

// The inbox remembers each report's key, so a repeated
// send is recognized, not stored twice. What each attempt
// meets is fixed: the first times out after the inbox has
// already stored the report; the second finds it down.
const stored = new Map<string, string>();
const outages = ['timeout', 'unavailable'];

function deliver(key: string, report: string, attempt: number): string {
  const outage = outages[attempt - 1];
  if (outage === 'unavailable') return 'unavailable (503)';
  if (stored.has(key)) return 'duplicate: already stored';
  stored.set(key, report);
  return outage === 'timeout' ? 'timeout: no reply' : 'stored';
}

// Bounded retries: a capped, doubling window with jitter
// inside it. The "random" rolls are fixed, so every run
// (and every language) prints the same.
const maxAttempts = 4;
const deadline = 20; // ticks on a fake clock; nothing sleeps
const rolls = [0.75, 0.4, 0.9];

const key = 'report-17'; // one key, reused on every retry
const report = 'crash log, build 2.4.1';
let now = 0;

for (let attempt = 1; ; attempt++) {
  const result = deliver(key, report, attempt);
  console.log(`t=${now} attempt ${attempt}: ${result}`);
  if (result === 'stored' || result.startsWith('duplicate')) break;
  if (attempt === maxAttempts) {
    console.log('stop: attempt limit');
    break;
  }
  const window = Math.min(8, 2 ** attempt); // 2, 4, 8, 8 ticks
  const wait = Math.max(1, Math.ceil(rolls[attempt - 1] * window));
  if (now + wait >= deadline) {
    console.log('stop: deadline');
    break;
  }
  now += wait;
}

console.log(`inbox holds ${stored.size} report(s)`);
What to look forOne key, one stored report

The waits come from a doubling window with a fixed jitter roll inside it: 2 ticks, then 2 again. The third attempt reaches the inbox, which recognizes the key and stores nothing new, so it holds one report. Give each attempt its own key and run it again: the inbox now holds two.

Read the arithmeticSeeded 32-bit stream and discrete ticks

The model uses a capped window of 2, 4, 8, then 8 ticks. Jitter advances a per-operation 32-bit LCG and rounds a random point up to this model's one-tick clock, with a one-tick minimum. This is quantized positive jitter, not the continuous formula used in every backoff reference.

TypeScript uses Math.imul plus >>> 0 to keep the stream in 32 bits.

The outcome classes in this modelAuthored protocol meanings
  • healthy accepts up to four reports in a tick; overflow is modeled as 429.
  • down is modeled as transient unavailability (503); it does not certify no effect.
  • invalid is an authored rejection before any report is applied.
  • unknown is a timeout whose report may already have been applied.

A parsed server wait is a lower bound for modeled transient replies. The scheduler does not parse an HTTP header, perform a send, or persist retry state.

04Recognize the frontend parallel

Queries and connections retry different work.

TanStack Query gives client queries a retry policy and an exponential delay capped at 30 seconds; server queries default to no retries. In its online network mode, a retry can pause while the browser is offline and resume when connectivity returns. These are query defaults, not a promise that arbitrary writes are safe to repeat.

A WebSocket reconnect retries a connection handshake. It does not replay a business operation or make one safe. In a reconnecting status badge, React or Svelte owns the visible state; the retry helper only chooses a wait or a stop.

Primary documentationVersioned claims can change

Provider behavior is provider-specific. The local example uses only an already-parsed delay and does not import a provider's retention, key format, or status guarantees.

05Choose one retry owner

Per-request limits do not bound the destination.

A worker with three attempts around an HTTP client with three attempts can send nine requests for one operation. Two locally careful loops multiply when both own retries. Choose one layer for the operation's attempt budget and make inner layers single-attempt when possible.

A limit of eight attempts per report still permits thousands of attempts across a large outage. A destination-wide retry budget or circuit breaker has a different owner and scope; this model contains neither. A stopped operation also needs a failure record and a human or later workflow to decide what happens next.

The worker retries up to 3 attempts. Months later it switches to an HTTP client that also retries 503s, up to 3 attempts. The destination stays down. How many requests does one report send?
Compare immediate retry, backoff, and jitterThe simpler option still has a place

For one cheap and repeat-safe request, the immediate loop can recover with little machinery.

When many callers may fail together, a capped wait spreads demand; jitter avoids giving them all the same next time. Neither reduces the required work if the receiver remains unavailable, and neither guarantees completion before the fixed deadline.

Use retry-after guidance only when the endpoint contract defines it. A retry budget controls aggregate retry load; the per-operation attempt count and deadline remain separate limits.

06Transfer the decision

Would another wait make this safe?

A report submission times out. The inbox may have accepted it, but it does not document duplicate recognition. Predict what the sender should do before you check.

What should the sender do next?
Connections to continueRelated Concepts lessons