Implementing Exponential Backoff for Failed Chunks
Retry a failed chunk with full-jitter exponential backoff, cap both the individual delay and the total wait, honour a clamped Retry-After, and make the PUT idempotent so a retry never duplicates or corrupts the upload.
Transient failures — a dropped socket, a 503 while a deployment rolls, a rate-limit 429 — should not kill a multi-gigabyte transfer. Disciplined retry is the heart of upload error recovery patterns within frontend UX, chunking and progress tracking. The trap is naive retry: fixed delays synchronise every client into a self-inflicted spike, unbounded retries hang the UI, and non-idempotent writes double-append data. This page implements the delay arithmetic properly, then shows how to share one backoff decision across all the chunk workers of a single file — building on the timeout foundations in browser timeout and retry logic.
When to use this approach
- You upload chunks over HTTP and need to survive intermittent
429,503and socket errors without restarting a file that is already 80% transferred. - You run more than one chunk in flight, so retries must spread out rather than converge on the same millisecond.
- Your chunk endpoint can be made idempotent through a deterministic key or a byte offset, so a retried PUT is provably safe. If it cannot, use retrying fetch uploads with idempotency keys to make it so before you add retries at all.
Backoff is the wrong tool for two neighbouring problems. A 413 or 507 will fail identically on every attempt no matter how long you wait — that needs a smaller chunk or a different destination, covered in handling 413 and 507 errors during uploads. A connection that is gone entirely needs an offline queue and a resume handshake, not a tighter loop; see resuming uploads after network loss.
Prerequisites
- A chunk endpoint accepting
PUTwith a stable per-chunk address (index or byte offset) that returns2xxfor a repeat write of identical bytes. - A server that emits
Retry-Afteron429and503when it wants to pace you. fetch,AbortSignal.timeout()andAbortSignal.any()— Node 20+, Chrome 116+, Safari 17.4+.- Chunk
Blobslices produced withBlob.slice, so a retry can re-read the same bytes without buffering them in memory.
How the delay is actually computed
Three numbers define the schedule: baseMs, the growth factor (always 2), and capMs. The window for retry number n is min(capMs, baseMs × 2^(n-1)). Full jitter then draws the actual delay uniformly from [0, window). The exponential term is what lets a slow-recovering backend breathe; the random draw is what stops ten thousand browsers from agreeing on when to come back.
With baseMs: 500 and capMs: 30_000, six retries produce this schedule:
| Retry | Window | Mean delay | Worst-case cumulative wait |
|---|---|---|---|
| 1 | 0–0.5 s | 0.25 s | 0.5 s |
| 2 | 0–1 s | 0.5 s | 1.5 s |
| 3 | 0–2 s | 1 s | 3.5 s |
| 4 | 0–4 s | 2 s | 7.5 s |
| 5 | 0–8 s | 4 s | 15.5 s |
| 6 | 0–16 s | 8 s | 31.5 s |
The mean total wait is 15.75 s and the worst case is 31.5 s — the cap never even engages, because 500 × 2^5 is still only 16 s. That is deliberate: the cap exists to protect you at retry 8 or 9, not to shape the common path. If you widen maxRetries past 8 without touching capMs, the tail jumps to minutes and users start reloading the tab.
Two implementation details break this arithmetic in the real world. First, setTimeout is not a clock. Chrome throttles timers in a backgrounded tab to one per second, and after five minutes of hidden inactivity to one per minute, so a nominal 8 s backoff can resolve at 60 s. Never derive elapsed time from the number of setTimeout calls; compare Date.now() against an absolute deadline captured when the chunk started. Second, a pending backoff must be abortable. A user who hits cancel during a 16 s wait should see the upload stop immediately, not sixteen seconds later, which means the sleep has to be wired to the same AbortSignal as the request. Both are handled by the delay() helper below.
Implementation
putChunkWithBackoff uploads one chunk. It classifies the failure, computes a full-jitter delay, prefers a clamped Retry-After when the server sent one, enforces both a retry count and a wall-clock budget, and attaches an idempotency key so a duplicate write is a no-op.
export interface ChunkInput {
uploadId: string;
index: number;
offset: number;
blob: Blob;
url: string;
}
/** Implemented by RetryGate below; optional, only needed for parallel chunks. */
export interface RetryCoordinator {
pauseAll(ms: number): void;
acquire(localDelayMs: number, signal?: AbortSignal): Promise<boolean>;
reportSuccess(): void;
}
export interface BackoffOptions {
maxRetries: number;
baseMs: number;
capMs: number;
perAttemptTimeoutMs: number;
maxRetryAfterMs: number;
totalBudgetMs: number;
gate?: RetryCoordinator;
signal?: AbortSignal;
onRetry?: (info: { index: number; attempt: number; delayMs: number; reason: string }) => void;
}
const DEFAULTS: BackoffOptions = {
maxRetries: 6,
baseMs: 500,
capMs: 30_000,
perAttemptTimeoutMs: 45_000,
maxRetryAfterMs: 120_000,
totalBudgetMs: 300_000,
};
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
export class PermanentChunkError extends Error {
constructor(readonly index: number, readonly status: number) {
super(`Permanent failure on chunk ${index}: HTTP ${status}`);
this.name = "PermanentChunkError";
}
}
/** setTimeout that rejects the moment the caller's signal aborts. */
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(id);
reject(signal!.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/** Full jitter: a uniform draw from [0, min(cap, base * 2^attempt)). */
function jitteredDelay(attempt: number, opts: BackoffOptions): number {
return Math.random() * Math.min(opts.capMs, opts.baseMs * 2 ** attempt);
}
function parseRetryAfter(header: string | null): number | null {
if (!header) return null;
const seconds = Number(header.trim());
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); // delta-seconds form
const date = Date.parse(header); // HTTP-date form
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
function isTransient(e: Error): boolean {
return e.name === "TimeoutError"
|| e.name === "NetworkError"
|| (e.name === "TypeError" && /fetch|network|load failed/i.test(e.message));
}
export async function putChunkWithBackoff(
chunk: ChunkInput,
options: Partial<BackoffOptions> = {},
): Promise<void> {
const opts: BackoffOptions = { ...DEFAULTS, ...options };
const deadline = Date.now() + opts.totalBudgetMs;
// Stable key => the server treats a retried PUT as the same write, not a second one.
const idempotencyKey = `${chunk.uploadId}:${chunk.index}`;
for (let attempt = 0; ; attempt++) {
const timeout = AbortSignal.timeout(opts.perAttemptTimeoutMs);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
let wait: number;
let reason: string;
try {
const res = await fetch(chunk.url, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
"Idempotency-Key": idempotencyKey,
"Content-Range": `bytes ${chunk.offset}-${chunk.offset + chunk.blob.size - 1}/*`,
},
body: chunk.blob,
signal,
});
if (res.ok) {
opts.gate?.reportSuccess();
return;
}
if (!RETRIABLE_STATUS.has(res.status)) {
throw new PermanentChunkError(chunk.index, res.status);
}
const serverDelay = parseRetryAfter(res.headers.get("Retry-After"));
wait = serverDelay === null
? jitteredDelay(attempt, opts)
: Math.min(serverDelay, opts.maxRetryAfterMs);
if (serverDelay !== null && (res.status === 429 || res.status === 503)) {
opts.gate?.pauseAll(wait); // one 429 should pause every worker, not just this one
}
reason = `HTTP ${res.status}`;
} catch (err) {
if (err instanceof PermanentChunkError) throw err;
if (opts.signal?.aborted) throw err; // the user cancelled; that is not a failure to retry
const e = err as Error;
if (!isTransient(e)) throw e;
wait = jitteredDelay(attempt, opts);
reason = e.name;
}
if (attempt >= opts.maxRetries) {
throw new Error(`Chunk ${chunk.index} failed after ${opts.maxRetries} retries (${reason})`);
}
if (Date.now() + wait > deadline) {
throw new Error(`Chunk ${chunk.index} exceeded its ${opts.totalBudgetMs} ms retry budget (${reason})`);
}
opts.onRetry?.({ index: chunk.index, attempt: attempt + 1, delayMs: Math.round(wait), reason });
if (opts.gate) {
const allowed = await opts.gate.acquire(wait, opts.signal);
if (!allowed) throw new Error(`Chunk ${chunk.index}: the upload's retry budget is exhausted`);
} else {
await delay(wait, opts.signal);
}
}
}
Line-by-line of the critical parts
RETRIABLE_STATUScontains only codes that can plausibly succeed on a repeat.400,403and422are decisions, not weather — retrying them burns the budget and hides the real bug, so they raisePermanentChunkErrorimmediately.409and412are deliberately absent: they mean the server’s idea of the upload offset disagrees with yours, and the fix is a reconciliation round trip, not a delay.jitteredDelayis full jitter,Math.random() * min(cap, base × 2^attempt), withattemptzero-indexed so the first retry draws from[0, 500 ms). The exponent grows the ceiling; the random factor decorrelates clients.parseRetryAfterusesNumber.isFinite, not!Number.isNaN.Number("")is0andNumber(" ")is0, so a header the proxy set to an empty string would silently become “retry now”.Number.isFiniterejectsInfinitytoo, which some misbehaving gateways emit.maxRetryAfterMsclamps the server. A CDN under attack can answerRetry-After: 3600. Waiting an hour inside a browser tab is not a retry, it is a hang; clamp to two minutes and let the outer loop give up cleanly instead.AbortSignal.any([opts.signal, timeout])merges the caller’s cancellation with a fresh per-attempt timeout. It must be created inside the loop — anAbortSignal.timeoutis a one-shot; reusing it across attempts means attempt 2 starts already aborted. The composition pattern is covered in depth in aborting uploads with AbortController and timeouts.opts.signal?.abortedis checked beforeisTransient. Both a timeout and a user cancel surface as a rejected fetch, and only the former deserves a retry. Note thatAbortErroris absent fromisTransientfor exactly this reason.totalBudgetMsis the wall-clock ceiling thatmaxRetriescannot express. Six retries of a chunk that each burn a 45 s timeout is 4.5 minutes of dead air; the deadline check fails fast with a message an operator can read.Idempotency-Key: uploadId:indexis the precondition for all of this. If a chunk stored successfully but the response was lost to a dropped socket, the retry carries the same key and the server replays the prior result instead of appending.Content-Rangegives the same guarantee for offset-addressed stores.onRetryexists so retries are visible. Feed it into whatever surfaces upload state — a retry that silently adds 16 s ruins any time-remaining estimate that assumes monotonic throughput.
Choosing a jitter strategy
Full jitter is the right default, but it is worth knowing what you are choosing against, because the four common strategies trade peak server load against tail latency in different directions.
| Strategy | Delay for retry n | Peak load after an outage | Tail latency | Use when |
|---|---|---|---|---|
| None | min(cap, base × 2^(n-1)) |
Worst — every client fires on the same grid | Lowest | A single client talking to a private endpoint |
| Equal jitter | w/2 + random(0, w/2) |
Moderate | Moderate | You need a guaranteed minimum spacing between attempts |
| Full jitter | random(0, w) |
Lowest | Slightly higher than none | Browser uploads with many concurrent clients |
| Decorrelated | min(cap, random(base, prev × 3)) |
Low | Lowest of the jittered set | Few clients, long outages, throughput matters more than fairness |
Full jitter’s only real cost is that a retry can fire almost immediately after a failure, which feels wasteful on the first attempt. In practice that is a feature for uploads: most 503s during a rolling deploy clear in under a second, and an early retry that succeeds saves the user a visible stall. Decorrelated jitter converges faster when an outage is long, because each delay is seeded from the previous actual delay rather than from the attempt number, but it needs per-chunk state and its arrivals bunch up more tightly than full jitter’s when thousands of tabs recover at once.
Coordinating backoff across parallel chunks
A per-chunk retry loop is not enough once you run four or six chunks concurrently. If the server answers 429 Too Many Requests, every in-flight worker will get its own 429, compute its own delay, and — worse — the workers that were not in flight will start fresh requests during the cool-down and earn another round of rejections. The rate limit is a property of the connection, not of chunk 41.
RetryGate fixes this with two shared pieces of state: an absolute cool-down instant that any worker can push forward, and a token bucket that bounds how many retries the whole file may spend.
import { delay, putChunkWithBackoff } from "./backoff.js";
import type { ChunkInput, RetryCoordinator } from "./backoff.js";
/** Shared backoff state for every in-flight chunk of one upload. */
export class RetryGate implements RetryCoordinator {
private cooldownUntil = 0;
private tokens: number;
private lastRefill = Date.now();
private readonly capacity: number;
private readonly refillPerMs: number;
constructor(capacity = 40, refillPerMinute = 12) {
this.capacity = capacity;
this.tokens = capacity;
this.refillPerMs = refillPerMinute / 60_000;
}
/** Any worker that sees a server-wide signal pauses all of them. */
pauseAll(ms: number): void {
this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ms);
}
private refill(): void {
const now = Date.now();
this.tokens = Math.min(this.capacity, this.tokens + (now - this.lastRefill) * this.refillPerMs);
this.lastRefill = now;
}
/** Resolves true when this worker may retry; false when the file is out of budget. */
async acquire(localDelayMs: number, signal?: AbortSignal): Promise<boolean> {
this.refill();
if (this.tokens < 1) return false;
this.tokens -= 1;
const wait = Math.max(localDelayMs, this.cooldownUntil - Date.now());
if (wait > 0) await delay(wait, signal);
return true;
}
/** A clean success repays part of the budget so long uploads are not starved. */
reportSuccess(): void {
this.refill();
this.tokens = Math.min(this.capacity, this.tokens + 0.25);
}
}
// Wire it up once per file, then hand the same instance to every chunk worker.
export async function uploadChunksWithGate(chunks: ChunkInput[], concurrency = 4): Promise<void> {
const gate = new RetryGate();
const queue = [...chunks];
const workers = Array.from({ length: concurrency }, async () => {
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
await putChunkWithBackoff(next, { gate });
}
});
await Promise.all(workers);
}
The token bucket is the part teams usually skip and then regret. Without it, a 4,000-chunk upload against a flaky backend can issue 24,000 retries and look, from the server’s access log, exactly like an attack — which is how legitimate users end up on the wrong side of presigned URL rate limiting. A capacity of 40 with a 12-per-minute refill lets a large file absorb roughly one retry per hundred chunks sustained, plus a burst of 40, and hard-fails anything worse. The 0.25-token repayment on success keeps a genuinely long, mostly healthy upload from starving after an early rough patch.
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
maxRetries |
number |
6 |
Attempts after the first. Above 8 the tail exceeds a minute even with the cap engaged. |
baseMs |
number |
500 |
Width of the first jitter window. Raise to 1000 for endpoints whose cold start exceeds a second. |
capMs |
number |
30_000 |
Ceiling on the window. Only engages from retry 7 at the default base. |
perAttemptTimeoutMs |
number |
45_000 |
Abort one attempt. Size it as chunkBytes / minBandwidth × 2; 45 s fits an 8 MB chunk at 400 kB/s. |
maxRetryAfterMs |
number |
120_000 |
Upper clamp on a server-supplied Retry-After, so a hostile or buggy value cannot hang the tab. |
totalBudgetMs |
number |
300_000 |
Wall-clock ceiling for one chunk including every wait. Fails fast when attempts are slow rather than merely numerous. |
gate |
RetryCoordinator |
undefined |
Shared cool-down and token budget. Pass one instance per file, not per chunk. |
signal |
AbortSignal |
undefined |
Cancels the in-flight request and any pending backoff sleep. |
onRetry |
(info) => void |
undefined |
Observability hook: index, attempt, delay and reason for every scheduled retry. |
Configuration gotchas
TimeoutError: The operation was aborted due to timeout on every attempt. perAttemptTimeoutMs is shorter than the time to push one chunk over a slow link, so every attempt fails at the same point and backoff just adds waiting to a guaranteed loss. Compute the timeout from chunk size and your floor bandwidth, or shrink the chunk — the sizing trade-off is worked through in best practices for handling 500MB file uploads.
409 Conflict {"error":"offset_mismatch","expected":52428800,"received":57671680}. A retry wrote bytes the server had already accepted, so the two offsets diverged. This is what an upload without an Idempotency-Key looks like from the server side. Adding retries to a non-idempotent endpoint makes corruption more likely, not less.
TypeError: Failed to fetch classified as permanent. In Chrome a CORS failure, a DNS failure and a genuine network drop all surface as this one opaque TypeError, with an empty message in some builds. Treating it as permanent kills recoverable uploads; treating it as transient makes a CORS misconfiguration retry six times before showing a useless error. Check navigator.onLine at the point of failure to split the two, and fix the preflight separately.
Retry storm after a server blip. Fixed or equal delays put every client back on the same grid, so the recovering backend gets a synchronised wave one second after it comes up. Full jitter — the literal Math.random() * window — is what spreads them. Do not “improve” it into a constant, and do not seed a shared PRNG across tabs.
Backoff that outlives the page. A 30 s wait scheduled just before the user navigates away resolves into a torn-down context and throws inside a floating promise. Always pass the AbortSignal you already use for the upload into delay(), and abort it on pagehide.
Verification
Stub fetch, force the failure sequence you care about, and assert the observed gaps against the windows the schedule promises. The first harness proves the exponential shape; the second proves that Retry-After wins and that the clamp holds.
const realFetch = globalThis.fetch;
const gaps: number[] = [];
let calls = 0;
let last = Date.now();
globalThis.fetch = async () => {
const now = Date.now();
if (calls > 0) gaps.push(now - last);
last = now;
calls += 1;
return calls <= 2 ? new Response(null, { status: 503 }) : new Response(null, { status: 200 });
};
await putChunkWithBackoff(
{
uploadId: "u1",
index: 0,
offset: 0,
blob: new Blob([new Uint8Array(1024)]),
url: "https://uploads.example.com/chunks/0",
},
{ baseMs: 100, capMs: 400, perAttemptTimeoutMs: 1_000 },
);
globalThis.fetch = realFetch;
console.assert(calls === 3, `expected 3 attempts, got ${calls}`);
console.assert(gaps[0] <= 120, `retry 1 must land inside the 0-100 ms window, was ${gaps[0]}`);
console.assert(gaps[1] <= 220, `retry 2 must land inside the 0-200 ms window, was ${gaps[1]}`);
console.log(`observed gaps: ${gaps.map((g) => `${g}ms`).join(", ")}`);
const realFetch2 = globalThis.fetch;
const started = Date.now();
let n = 0;
globalThis.fetch = async () => {
n += 1;
return n === 1
? new Response(null, { status: 429, headers: { "Retry-After": "600" } })
: new Response(null, { status: 200 });
};
await putChunkWithBackoff(
{
uploadId: "u2",
index: 7,
offset: 0,
blob: new Blob(["chunk"]),
url: "https://uploads.example.com/chunks/7",
},
{ maxRetryAfterMs: 250, totalBudgetMs: 5_000 },
);
globalThis.fetch = realFetch2;
const waited = Date.now() - started;
console.assert(waited >= 250 && waited < 1_000, `expected a clamped ~250 ms wait, got ${waited}`);
console.log("Retry-After honoured and clamped to maxRetryAfterMs");
Against the real endpoint, confirm the server actually sends the header you are parsing — a proxy in front of it will happily strip Retry-After from a 429:
curl -s -o /dev/null -D - -X PUT \
-H 'Idempotency-Key: u1:0' \
-H 'Content-Type: application/octet-stream' \
-H 'Content-Range: bytes 0-1048575/*' \
--data-binary @chunk-000.bin \
https://uploads.example.com/chunks/0
# HTTP/1.1 429 Too Many Requests
# retry-after: 8
# x-ratelimit-remaining: 0
Then replay the identical command. A correctly idempotent endpoint answers 200 or 204 with the same ETag and does not advance the upload offset — that is the property the whole retry loop depends on.
Frequently Asked Questions
Why full jitter instead of plain exponential backoff?
Plain exponential backoff keeps every client’s retries aligned to the same grid, so a recovering server is hit by synchronised waves at 1 s, 2 s and 4 s. Full jitter draws each delay uniformly across the whole window, flattening aggregate arrivals at the cost of a slightly longer mean wait per client. With more than a handful of concurrent uploaders it is the only variant that reliably avoids a second outage caused by the recovery.
Should Retry-After always win over my computed delay?
Yes, but clamp it. The server knows its own recovery timeline, and ignoring the header is how a temporary 429 becomes a longer block. A value of 3600 from a CDN under attack is still not something a browser tab should honour literally, so take Math.min(serverDelay, maxRetryAfterMs) and let the retry ceiling end the attempt cleanly instead.
How many retries is reasonable for an interactive upload?
Six, with a 30 s cap and a 5-minute per-chunk wall-clock budget. That covers roughly 32 s of accumulated waiting in the worst case, which absorbs almost every rolling deploy and rate-limit window without freezing the interface. Past that, stop retrying and hand the chunk to a durable queue so the user can close the tab — persisting upload state in IndexedDB makes that recoverable on the next visit.
Do I need a separate backoff instance per chunk?
Each chunk needs its own attempt counter and deadline, but they should share one RetryGate. Per-chunk counters keep a single unlucky chunk from spending the whole file’s allowance, while the shared gate makes a 429 seen by one worker pause the rest and draws all retries from one token budget.
Does backoff help with a 413 Payload Too Large?
No. 413 is deterministic — the same bytes will be rejected identically in thirty seconds — so it is deliberately absent from RETRIABLE_STATUS. The fix is to renegotiate the chunk size or raise the limit at the hop that rejected you, which is a different recovery path entirely.