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.
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
// 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.
| Scenario | Attempts | Delivered | Stopped | Peak retries / tick | Ticks with sends |
|---|---|---|---|---|---|
| One report, one brief blip | 2 | 1 | 0 | 1 | t0, t1 |
| Twelve reports · immediate | 60 | 12 | 0 | 12 | t0–t5 |
| Twelve reports · backoff | 48 | 12 | 0 | 12 | t0, t2, t6, t14, t22 |
| Twelve reports · jitter | 39 | 12 | 0 | 9 | t0–t6 |
| Destination stays down | 86 | 0 | 12 | 9 | t0–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.
// 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.
// 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
healthyaccepts up to four reports in a tick; overflow is modeled as 429.downis modeled as transient unavailability (503); it does not certify no effect.invalidis an authored rejection before any report is applied.unknownis 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
- TanStack Query · retry guide
- TanStack Query · network modes
- Stripe · idempotent requests
- AWS Architecture Blog · backoff and jitter
- RFC 9110 · Retry-After
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.
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.
Connections to continueRelated Concepts lessons
- Idempotency and at-least-once follows the receiver's duplicate responsibility.
- Backpressure and queues covers admitting work faster than a destination can accept it.
- Timeouts, deadlines, and races covers the time bound on an individual attempt.