Upload Error Recovery Patterns

A long upload fails in the middle far more often than it fails at the start, and by then the client is holding state the server may or may not agree with. The whole difference between a robust uploader and a fragile one is what happens in the 200 milliseconds after a PATCH rejects: whether the code can tell a dead credential from a congested load balancer, whether the retry lands on the same byte range, and whether it waits long enough that it is not part of the problem.

This topic sits under Frontend UX, Chunking & Progress Tracking and supplies the transitions that drive the retrying state in resumable upload state machines. The goal is to turn every recoverable error into a checkpoint rather than a restart: classify the failure, back off with jitter, resend the same chunk idempotently against the same offset, and pause cleanly when the device goes offline. Everything below is client-side, roughly 250 lines of TypeScript in total, and every branch exists because a specific real failure ate somebody’s 4 GB upload.

Prerequisites

  • [ ] Node 20+ with a modern ESM bundler (Vite 5, esbuild 0.21+, or Rollup 4)
  • [ ] TypeScript 5.x with strict and exactOptionalPropertyTypes enabled
  • [ ] An idempotent chunk endpoint keyed by byte offset or part number — tus PATCH, S3 UploadPart, or your own equivalent
  • [ ] A durable offset store so retries resume from a checkpoint; see persisting upload state in IndexedDB
  • [ ] Access to the browser online/offline events and navigator.onLine
  • [ ] A way to reissue expired credentials from your backend, typically the same route that mints S3 presigned URL workflows

How error recovery works

Recovery is a classifier followed by a scheduler. The classifier decides whether an error means try again, fix something first, or stop. The scheduler decides when. Getting either one wrong is expensive in opposite directions: retrying a fatal error burns the budget and delays the honest error message the user needs, while refusing to retry a transient one drops an upload that was one second away from succeeding.

Not all errors deserve a retry. A 400, 401, 403, 404, or 422 is the server telling you the request itself is wrong — repeating it byte for byte repeats the rejection. A 500, 502, 503, 504, 408, or 429, or a raw network failure with no response at all, is transient. And a third group — 409, 412, an expired signature, a 413 — is neither: those are actionable, meaning the client must change something (its offset, its credential, its chunk size) before the next attempt makes any sense.

Retry and backoff decision flow A failed chunk is classified as fatal or retryable; retryable errors check the attempt budget and online status, wait a jittered backoff, then resume from the checkpoint offset. Chunk fails Classify status / no response fatal (4xx) failed retryable Budget left? attempt < max Online? wait if offline Jittered backoff then resume @ offset retry chunk
A failed chunk is classified; fatal errors terminate, retryable ones check budget and connectivity, wait a jittered backoff, and resume from the checkpoint offset.

The signals you actually get, and what each one means

Browsers are unhelpfully vague about network failures. A fetch() that fails at the transport layer rejects with TypeError: Failed to fetch in Chrome, TypeError: NetworkError when attempting to fetch resource. in Firefox, and TypeError: Load failed in Safari. None of them tells you whether the socket died, DNS failed, or a CORS preflight was rejected. That ambiguity is exactly why the classifier’s default branch matters: an unknown throw with no status is treated as transient, because a wasted retry costs a few seconds while a wrongly-fatal classification costs the whole upload.

The table below is the operational version of that reasoning. It is worth encoding literally, because the interesting rows are the middle ones — the ones that must not consume the retry budget, since nothing about waiting longer would help them.

Upload error taxonomy: signal, class, recovery action, budget cost A seven-row matrix mapping HTTP status groups and network failures to an error class, the recovery action the loop should take, and whether the failure consumes an attempt from the retry budget. Signal to class to recovery action Signal Class Recovery action Costs an attempt No response / TypeError transient back off, resend same offset yes 408, 429 throttled honour Retry-After, resend yes 500, 502, 503, 504 server-side back off, resend same offset yes 409, 412 desynchronised re-handshake, adopt server offset no 403 on a signed URL credential reissue the URL, resend at once no 413, 507 capacity shrink the chunk or ask the user no 400, 401, 404, 422 fatal abort and surface the reason n/a Unknown statuses default to transient: a wasted retry is cheaper than a dropped upload.
The middle three rows are the ones most implementations miss — they are recoverable, but only after the client changes something, so they must not spend the retry budget.

Why jitter, and not just exponential growth

Retries must back off and they must jitter. The two do different jobs. Exponential growth stops a single client from hammering a struggling endpoint; jitter stops a fleet of clients from hammering it in synchronised waves. If 400 browsers all lose their connection when a load balancer restarts, and all of them use a fixed 2-second retry, then the recovering server sees 400 requests at t=2 s, another 400 at t=4 s, and falls over again. Deterministic exponential backoff does not fix this — the clients are still in lockstep, just with wider gaps.

Full jitter draws the actual wait uniformly from [0, min(cap, base * 2^attempt)). That converts a spike into a flat arrival rate, and it also gives each individual client a fair chance of being the one that gets through early. The AWS-documented alternatives — equal jitter and decorrelated jitter — narrow the spread in exchange for a higher floor; full jitter has the best de-correlation and is the right default for browser uploads, where you rarely control how many tabs are retrying at once.

Full-jitter wait window per attempt Six horizontal bars showing the sampling window for attempts one to six with a 500 millisecond base and a 30 second cap, each with one sampled wait marked inside it. Full-jitter wait window per attempt (base 500 ms, cap 30 s) cap attempt 1 attempt 2 attempt 3 attempt 4 attempt 5 attempt 6 0–1,000 ms 0–2,000 ms 0–4,000 ms 0–8,000 ms 0–16,000 ms 0–30,000 ms (capped) 0 s 8 s 16 s 24 s 32 s vertical mark = one sampled wait; the bar is the range it is drawn from
The window doubles each attempt but the wait is sampled uniformly inside it, so attempt 5 can be shorter than attempt 4 — that irregularity is the point.

Two consequences fall out of the chart. First, the total worst-case time to exhaust six attempts is bounded by roughly 61 seconds of waiting, not by the sum of the caps — worth knowing when you size a user-facing “still trying” message. Second, because the sample can be near zero, a lucky client recovers almost instantly; you should not add an artificial minimum wait “so it looks like it’s doing something”. The deeper mechanics, including decorrelated jitter and per-chunk versus per-file budgets, are worked through in implementing exponential backoff for failed chunks.

Idempotency is the precondition, not an optimisation

None of this is safe unless a resent chunk is a no-op when the server already has those bytes. A retry is, by definition, an at-least-once delivery: the request may have reached the server, been committed, and had its response lost on the way back. If your endpoint appends rather than overwrites, that scenario silently duplicates a chunk and corrupts the assembled file — and you will not notice until a checksum mismatch weeks later.

There are two ways to get idempotency, and you want one of them before you write a single line of retry code. Positional addressing keys the write by absolute byte offset (tus Upload-Offset) or part number (S3 partNumber), so re-delivery overwrites the same range. Key-based addressing sends a client-generated token that the server deduplicates against, which is the model described in retrying fetch uploads with idempotency keys. Positional is simpler for chunked transfers because the key is already implied by the data.

Step-by-step implementation

The seven steps below build one small module per concern. They compose into a loop you can drop into any chunk scheduler, and each file is independently testable.

Step 1: Model the error so the classifier has something to read

Wrap every failure in a single error type that carries the status, the response headers you care about, and whether a body was ever received. Without this, the classifier ends up string-matching browser messages, which differ per engine and per locale.

// errors.ts
export type ErrorClass =
  | "transient"
  | "throttled"
  | "desynchronised"
  | "credential"
  | "capacity"
  | "fatal";

export class UploadError extends Error {
  readonly status: number | null;
  readonly retryAfter: string | null;
  readonly serverOffset: number | null;

  constructor(
    message: string,
    status: number | null,
    opts: { retryAfter?: string | null; serverOffset?: number | null } = {},
  ) {
    super(message);
    this.name = "UploadError";
    this.status = status;
    this.retryAfter = opts.retryAfter ?? null;
    this.serverOffset = opts.serverOffset ?? null;
  }
}

export function toUploadError(cause: unknown): UploadError {
  if (cause instanceof UploadError) return cause;
  const message = cause instanceof Error ? cause.message : String(cause);
  return new UploadError(message, null);
}

Expected: toUploadError(new TypeError("Failed to fetch")).status is null, which every downstream branch reads as “no response was received”.

Step 2: Classify

Keep the fatal set explicit and small, so an unexpected status defaults to a cautious retry rather than a silent drop. Returning a class rather than a boolean is what lets the loop treat 409 and 403 differently from 503.

// classify.ts
import { UploadError, type ErrorClass } from "./errors.js";

const FATAL = new Set([400, 401, 404, 405, 410, 422]);
const CAPACITY = new Set([413, 507]);

export function classify(err: UploadError): ErrorClass {
  const { status } = err;
  if (status === null) return "transient";
  if (status === 408 || status === 429) return "throttled";
  if (status === 409 || status === 412) return "desynchronised";
  if (status === 403) return "credential";
  if (CAPACITY.has(status)) return "capacity";
  if (FATAL.has(status)) return "fatal";
  if (status >= 500) return "transient";
  return "transient";
}

export function consumesAttempt(cls: ErrorClass): boolean {
  return cls === "transient" || cls === "throttled";
}

Expected: classify(new UploadError("x", 503)) is "transient", classify(new UploadError("x", 409)) is "desynchronised", and consumesAttempt("credential") is false.

Note the deliberate choice on 403. On an S3 presigned PUT an expired signature returns 403 with <Code>AccessDenied</Code> and the message Request has expired, which is recoverable by minting a fresh URL — so it is a credential class, not fatal. A genuinely unauthorised caller also returns 403, and that one is fatal; the difference is decided one level up by whether reissuing the URL succeeds.

Step 3: Compute the wait

Grow exponentially, cap it, randomise across the whole interval, and let the server override you when it has said something concrete.

// backoff.ts
export interface BackoffConfig {
  baseMs: number;
  capMs: number;
  jitter: "full" | "none";
}

export const DEFAULT_BACKOFF: BackoffConfig = {
  baseMs: 500,
  capMs: 30_000,
  jitter: "full",
};

export function backoffDelay(attempt: number, cfg = DEFAULT_BACKOFF): number {
  const bound = Math.min(cfg.capMs, cfg.baseMs * 2 ** attempt);
  return cfg.jitter === "full" ? Math.floor(Math.random() * bound) : bound;
}

/** Retry-After is either delta-seconds or an HTTP-date. */
export function retryAfterMs(header: string | null): number | null {
  if (!header) return null;
  const secs = Number(header.trim());
  if (Number.isFinite(secs) && secs >= 0) return secs * 1000;
  const when = Date.parse(header);
  return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
}

export function nextDelayMs(
  attempt: number,
  retryAfter: string | null,
  cfg = DEFAULT_BACKOFF,
  honorRetryAfter = true,
): number {
  const server = honorRetryAfter ? retryAfterMs(retryAfter) : null;
  if (server !== null) return Math.min(server, cfg.capMs);
  return backoffDelay(attempt, cfg);
}

Expected: backoffDelay(0) returns 0–499 ms, backoffDelay(3) returns 0–3,999 ms, every attempt is capped at 30,000 ms, and retryAfterMs("2") returns 2000. Clamping the server value to capMs matters: a misconfigured proxy that answers Retry-After: 3600 should not park the upload for an hour without telling the user.

Step 4: Gate retries on connectivity

Retrying while the radio is off burns the attempt budget against a dead link and finishes the budget before the user has left the lift. Wait for the online event instead, so backoff measures real connectivity rather than wall-clock time spent in a tunnel.

// connectivity.ts
export function waitUntilOnline(signal?: AbortSignal): Promise<void> {
  if (navigator.onLine) return Promise.resolve();
  return new Promise((resolve, reject) => {
    const cleanup = () => {
      window.removeEventListener("online", onOnline);
      signal?.removeEventListener("abort", onAbort);
    };
    const onOnline = () => {
      cleanup();
      resolve();
    };
    const onAbort = () => {
      cleanup();
      reject(new DOMException("Upload aborted while offline", "AbortError"));
    };
    window.addEventListener("online", onOnline);
    signal?.addEventListener("abort", onAbort);
  });
}

Expected: while offline, the promise stays pending and no attempts are consumed; the moment the browser fires online it resolves and the loop continues. Cancelling the upload rejects with an AbortError instead of leaking a listener — the same abort plumbing described in aborting uploads with AbortController and timeouts. The full offline queueing strategy, including what to do about multi-hour disconnections, lives in resuming uploads after network loss.

Step 5: Send a chunk idempotently

Address the same bytes every time. The Upload-Offset request header states where these bytes belong; the response header of the same name is the server’s new authoritative offset, and it is the value you persist.

// chunk.ts
import { UploadError } from "./errors.js";

export interface ChunkResult {
  serverOffset: number;
}

export async function putChunkAt(
  endpoint: string,
  uploadId: string,
  blob: Blob,
  offset: number,
  signal?: AbortSignal,
): Promise<ChunkResult> {
  let res: Response;
  try {
    res = await fetch(`${endpoint}/${uploadId}`, {
      method: "PATCH",
      headers: {
        "Upload-Offset": String(offset),
        "Content-Type": "application/offset+octet-stream",
        "Tus-Resumable": "1.0.0",
      },
      body: blob,
      signal,
    });
  } catch (cause) {
    if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
    throw new UploadError("network failure before response", null);
  }

  const advertised = res.headers.get("Upload-Offset");
  const serverOffset = advertised === null ? null : Number(advertised);

  if (!res.ok) {
    throw new UploadError(`chunk rejected with ${res.status}`, res.status, {
      retryAfter: res.headers.get("Retry-After"),
      serverOffset,
    });
  }
  if (serverOffset === null || !Number.isFinite(serverOffset)) {
    throw new UploadError("server did not return Upload-Offset", null);
  }
  return { serverOffset };
}

Expected: a successful PATCH returns 204 No Content with Upload-Offset: 12582912; re-sending the same offset after a lost response yields the identical committed object because the server overwrites that range rather than appending.

Step 6: Reconcile a stale checkpoint

A 409 means the client’s idea of the offset and the server’s have diverged — usually because a previous chunk did commit but its response was lost, or because a second tab uploaded into the same session. Blind retries loop forever here. The fix is a HEAD handshake that adopts the server’s number.

Re-handshake sequence after a 409 offset conflict A sequence diagram in which the uploader sends a PATCH at a stale offset, receives a 409, issues a HEAD request to learn the committed offset, and resends the chunk at the corrected offset. Uploader Upload API Object store PATCH Upload-Offset: 5242880 check committed range already at 7340032 409 Conflict — offset mismatch HEAD /uploads/abc123 204 Upload-Offset: 7340032 PATCH Upload-Offset: 7340032 204 Upload-Offset: 12582912 The 409 round trip corrects state, so it must not consume the retry budget.
A conflict is a state correction, not a failure: one extra round trip re-synchronises the client and the next PATCH lands on the right bytes.
// resync.ts
import { UploadError } from "./errors.js";

export async function readServerOffset(
  endpoint: string,
  uploadId: string,
  signal?: AbortSignal,
): Promise<number> {
  const res = await fetch(`${endpoint}/${uploadId}`, {
    method: "HEAD",
    headers: { "Tus-Resumable": "1.0.0", "Cache-Control": "no-store" },
    signal,
  });
  if (res.status === 404 || res.status === 410) {
    throw new UploadError("upload session expired", res.status);
  }
  const header = res.headers.get("Upload-Offset");
  const offset = header === null ? Number.NaN : Number(header);
  if (!Number.isFinite(offset)) {
    throw new UploadError("HEAD returned no usable Upload-Offset", res.status);
  }
  return offset;
}

Expected: HEAD answers 204 with Upload-Offset: 7340032; a session the server has garbage-collected answers 404 or 410, which is fatal and must restart the upload from zero. Make sure the Upload-Offset header is in your bucket’s ExposeHeaders list, or the browser will hand you null even on a 204 — that failure mode and its CORS fix are covered in fixing CORS preflight errors on S3 uploads.

Step 7: Assemble the loop

Now compose everything. The loop tracks two independent counters — attempts (spent by transient and throttled failures) and corrections (spent by conflicts and credential refreshes) — so a pathological server cannot spin it forever.

// recover.ts
import { classify, consumesAttempt } from "./classify.js";
import { nextDelayMs, DEFAULT_BACKOFF, type BackoffConfig } from "./backoff.js";
import { toUploadError, UploadError } from "./errors.js";
import { waitUntilOnline } from "./connectivity.js";

export interface RecoveryDeps {
  send: (offset: number) => Promise<number>;
  resync: () => Promise<number>;
  reissueCredential: () => Promise<void>;
  onCheckpoint: (offset: number) => Promise<void>;
}

export interface RecoveryOptions {
  maxAttempts?: number;
  maxCorrections?: number;
  backoff?: BackoffConfig;
  honorRetryAfter?: boolean;
  signal?: AbortSignal;
}

const sleep = (ms: number, signal?: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);
    function onAbort() {
      clearTimeout(timer);
      reject(new DOMException("Upload aborted", "AbortError"));
    }
    signal?.addEventListener("abort", onAbort, { once: true });
  });

export async function uploadWithRecovery(
  deps: RecoveryDeps,
  startOffset: number,
  options: RecoveryOptions = {},
): Promise<number> {
  const maxAttempts = options.maxAttempts ?? 6;
  const maxCorrections = options.maxCorrections ?? 3;
  const backoff = options.backoff ?? DEFAULT_BACKOFF;
  const honorRetryAfter = options.honorRetryAfter ?? true;

  let offset = startOffset;
  let attempt = 0;
  let corrections = 0;

  for (;;) {
    try {
      const committed = await deps.send(offset);
      await deps.onCheckpoint(committed);
      return committed;
    } catch (cause) {
      if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
      const err = toUploadError(cause);
      const cls = classify(err);

      if (cls === "fatal" || cls === "capacity") throw err;

      if (cls === "desynchronised" || cls === "credential") {
        corrections += 1;
        if (corrections > maxCorrections) {
          throw new UploadError(`gave up after ${corrections} corrections`, err.status);
        }
        if (cls === "credential") await deps.reissueCredential();
        else {
          offset = err.serverOffset ?? (await deps.resync());
          await deps.onCheckpoint(offset);
        }
        continue;
      }

      attempt += 1;
      if (!consumesAttempt(cls) || attempt >= maxAttempts) throw err;
      await waitUntilOnline(options.signal);
      await sleep(nextDelayMs(attempt, err.retryAfter, backoff, honorRetryAfter),
        options.signal);
    }
  }
}

Expected: a single 503 triggers one jittered wait then a successful resend; six straight transient failures throw after the budget is spent; a 422 throws immediately; a 409 costs a correction, not an attempt, and the loop resumes at the server’s offset. Because onCheckpoint runs on both the success path and the conflict path, the durable offset never lags behind the server.

Step 8: Budget across the file, not just the chunk

A per-chunk budget of six attempts sounds strict until you multiply it by 400 chunks. A file being uploaded over a genuinely broken link can spend an hour retrying 2,400 times before anything surfaces to the user. Add a file-level circuit breaker: track consecutive chunk failures across the whole transfer and stop the scheduler when that count crosses a threshold.

// breaker.ts
export class UploadCircuitBreaker {
  private consecutiveFailures = 0;
  private readonly threshold: number;

  constructor(threshold = 12) {
    this.threshold = threshold;
  }

  recordSuccess(): void {
    this.consecutiveFailures = 0;
  }

  recordFailure(): void {
    this.consecutiveFailures += 1;
  }

  get isOpen(): boolean {
    return this.consecutiveFailures >= this.threshold;
  }

  assertClosed(): void {
    if (this.isOpen) {
      throw new Error(
        `upload halted: ${this.consecutiveFailures} consecutive chunk failures`,
      );
    }
  }
}

Expected: twelve consecutive chunk failures open the breaker and the scheduler stops issuing work, so the UI can offer “pause and try later” instead of grinding. One success anywhere resets the counter, which keeps the breaker from tripping on a merely lossy connection that is still making forward progress.

Configuration reference

Option Type Default Effect
maxAttempts number 6 Transient/throttled retries per chunk before it transitions to failed
maxCorrections number 3 Conflict and credential-refresh cycles allowed per chunk before giving up
baseMs number 500 First backoff bound; each attempt doubles it
capMs number 30_000 Upper bound on any single wait, including a server Retry-After
jitter "full" | "none" "full" "full" samples uniformly in [0, bound); "none" waits the bound exactly
honorRetryAfter boolean true Prefer the server’s Retry-After over the computed backoff
breakerThreshold number 12 Consecutive chunk failures across the file before the scheduler halts
signal AbortSignal Cancels a pending wait, an offline pause, and the in-flight request
chunkSize number 8 * 1024 * 1024 Bytes per PATCH; smaller chunks lose less work per retry

The chunk-size row is the one people tune last and should tune first. An 8 MB chunk on a 2 Mbps uplink takes 32 seconds, so a failure at 90% throws away 29 seconds of transfer; a 2 MB chunk throws away 7. The counter-pressure is per-request overhead and the number of round trips, and the trade-off is quantified in slicing large files with Blob.slice.

Observability: make failures explainable

Retry logic that works is invisible, which means the first time you look at it will be during an incident. Emit one structured record per attempt and you can answer “why did this upload take nine minutes?” without reproducing it.

// telemetry.ts
import type { ErrorClass } from "./errors.js";

export interface AttemptRecord {
  uploadId: string;
  offset: number;
  attempt: number;
  outcome: "ok" | ErrorClass;
  status: number | null;
  waitedMs: number;
  durationMs: number;
  online: boolean;
}

const buffer: AttemptRecord[] = [];

export function record(entry: AttemptRecord): void {
  buffer.push(entry);
  if (buffer.length >= 25) flush();
}

export function flush(): void {
  if (buffer.length === 0) return;
  const batch = buffer.splice(0, buffer.length);
  const body = JSON.stringify({ attempts: batch });
  // sendBeacon survives page unload; fall back to fetch with keepalive.
  if (!navigator.sendBeacon("/telemetry/upload-attempts", body)) {
    void fetch("/telemetry/upload-attempts", {
      method: "POST",
      body,
      keepalive: true,
      headers: { "Content-Type": "application/json" },
    });
  }
}

window.addEventListener("pagehide", flush);

Expected: a healthy upload of a 1 GB file at 8 MB chunks emits 128 records with outcome: "ok"; a flaky one shows the classes and the waits, and the sum of waitedMs tells you immediately whether time went into backoff or into slow transfer. Three derived metrics are worth charting: retry rate per 1,000 chunks, the fraction of attempts classed desynchronised (a rising number means your checkpoint writes are lagging), and p95 waitedMs (a rising number means the server is throttling you). Feeding the same signal into the UI is what keeps showing accurate time-remaining estimates honest during a retry storm.

Edge cases and gotchas

Retry storms after a server recovers

If every client retries on the same schedule, a recovering server is immediately re-flooded and knocked back down. Full jitter is the fix — never use fixed or equal-jitter intervals for a fleet of uploaders pointed at one endpoint. If you also control the server, pair it with Retry-After on your 503s so the fleet’s arrival curve is something you can steer rather than merely hope about.

Stale checkpoint causing 409 or 412 loops

When the persisted offset disagrees with the server, blind retries loop forever and each one transfers the chunk again before being rejected. Treat 409/412 as a signal to re-run the resume handshake and adopt the server’s offset before resending, and cap the number of corrections so a server that is always conflicting fails loudly instead of quietly consuming the user’s data allowance.

Token expiry mid-upload

A presigned URL that has passed its expiry returns 403 with Request has expired in the XML body — and retrying the dead URL is pointless no matter how long you wait. Catch the credential class, request a fresh URL from your backend, and resend immediately without counting it against the retry budget. Set the expiry generously relative to your chunk size: a 15-minute signature and a 100 MB chunk on a slow uplink is a guaranteed failure, because the signature dies mid-body.

navigator.onLine only reports whether the OS believes a network interface is up, not whether the internet is reachable. Captive portals, VPNs that have dropped their tunnel, and Wi-Fi with no upstream all report true. Use it to pause aggressively, but never as proof that a retry will work — real request failures plus backoff remain the authority.

The retry that succeeds twice

The nastiest case is the request that reaches the server, commits, and then loses its response to a dead socket. The client sees a network error and resends; the server sees the same bytes at the same offset. With positional addressing this is harmless. With an append-style endpoint it duplicates data, and with a checksum-verified upload it produces the maddening symptom of a file that is exactly one chunk too long. If you cannot make writes positional, verify the assembled object against a client-computed digest before marking the upload complete.

Backoff that outlives the session

Multipart uploads and tus sessions both expire. If your backoff reaches the 30-second cap and the breaker allows twelve consecutive failures, an upload can sit idle for six minutes — longer than some proxy idle timeouts, and long enough to collide with an aggressive lifecycle rule. Check what your bucket does with abandoned parts, because a rule that reaps them after a day is fine while one that reaps after an hour will fight your retry loop; see expiring incomplete multipart uploads automatically.

Aborts must not look like failures

A user pressing Cancel and a socket dying both surface as a rejected fetch(). If you classify AbortError as transient, cancelling an upload starts a retry loop that ignores the cancellation. Check cause.name === "AbortError" before wrapping anything in UploadError, and rethrow it untouched — every function above does exactly that.

Retained Blob slices and memory growth

A retry needs the original bytes, so the scheduler must hold a reference to each in-flight Blob slice until it commits. That is cheap — a Blob slice is a view, not a copy — but the moment you call arrayBuffer() on one for hashing, you materialise the whole chunk in memory. With eight concurrent 8 MB chunks plus retries queued behind them, a naive implementation can hold 200 MB and get killed on a low-end Android device. Hash while streaming or hash before the upload starts, never inside the retry path.

413 is not a backoff problem

A 413 Payload Too Large means a hop in the chain has a smaller body limit than your chunk size — often an Nginx client_max_body_size of 1 MB, or a CDN limit, and it will return exactly the same response to attempt six as to attempt one. Halve the chunk size and restart the current chunk, or surface a clear error; the diagnosis for which hop rejected you is in handling 413 and 507 errors during uploads, and the server-side limits themselves in raising Nginx and Cloudflare upload size limits.

Verification

Prove three things separately: the classifier maps statuses correctly, the loop actually retries and recovers, and the resumed upload produces a byte-identical object.

Start with the pure functions, which need no network at all.

// verify-classifier.ts
import { classify, consumesAttempt } from "./classify.js";
import { UploadError } from "./errors.js";
import { retryAfterMs } from "./backoff.js";

const cases: Array<[UploadError, string]> = [
  [new UploadError("gateway", 502), "transient"],
  [new UploadError("throttled", 429), "throttled"],
  [new UploadError("conflict", 409), "desynchronised"],
  [new UploadError("expired", 403), "credential"],
  [new UploadError("too big", 413), "capacity"],
  [new UploadError("bad body", 422), "fatal"],
  [new UploadError("no response", null), "transient"],
];

for (const [err, expected] of cases) {
  const actual = classify(err);
  console.assert(actual === expected, `${err.status}: ${actual} !== ${expected}`);
}
console.assert(consumesAttempt("desynchronised") === false, "409 must be free");
console.assert(retryAfterMs("2") === 2000, "delta-seconds parsed");
console.assert(retryAfterMs("not-a-date") === null, "garbage ignored");
console.log("classifier ok");

Then drive the loop against a stub that fails on a schedule, which is far more reliable than trying to unplug your Wi-Fi at the right moment.

// verify-loop.ts
import { uploadWithRecovery } from "./recover.js";
import { UploadError } from "./errors.js";

const script: Array<number | null> = [503, null, 409, 204];
let step = 0;
let serverOffset = 7_340_032;

const committed = await uploadWithRecovery(
  {
    send: async (offset) => {
      const status = script[step++] ?? 204;
      if (status === 204) return offset + 8_388_608;
      if (status === 409) throw new UploadError("conflict", 409, { serverOffset });
      throw new UploadError(`stub ${status}`, status);
    },
    resync: async () => serverOffset,
    reissueCredential: async () => {},
    onCheckpoint: async (offset) => {
      serverOffset = offset;
    },
  },
  5_242_880,
  { backoff: { baseMs: 5, capMs: 20, jitter: "full" } },
);

console.assert(committed === 15_728_640, `committed ${committed}`);
console.log("loop recovered, committed offset", committed);

Expected output: loop recovered, committed offset 15728640. The script exercises a 503, a bare network failure, a conflict that rewrites the offset to 7,340,032, and finally a success — proving the loop retried twice, corrected once, and resumed from the server’s number rather than its own.

Finally confirm the wire behaviour by hand. A HEAD before and after a deliberate failure shows the offset advancing monotonically, which is the property everything else rests on.

# 1. Where does the server think we are?
curl -sS -I -H 'Tus-Resumable: 1.0.0' https://api.example.com/uploads/abc123
# HTTP/1.1 204 No Content
# Upload-Offset: 7340032
# Upload-Length: 1073741824

# 2. Send the next 8 MiB at the stale offset on purpose.
curl -sS -i -X PATCH https://api.example.com/uploads/abc123 \
  -H 'Tus-Resumable: 1.0.0' \
  -H 'Upload-Offset: 5242880' \
  -H 'Content-Type: application/offset+octet-stream' \
  --data-binary @chunk-01.bin
# HTTP/1.1 409 Conflict
# Upload-Offset: 7340032

# 3. Resend at the offset the server just told us. Idempotent, so safe to repeat.
curl -sS -i -X PATCH https://api.example.com/uploads/abc123 \
  -H 'Tus-Resumable: 1.0.0' \
  -H 'Upload-Offset: 7340032' \
  -H 'Content-Type: application/offset+octet-stream' \
  --data-binary @chunk-01.bin
# HTTP/1.1 204 No Content
# Upload-Offset: 15728640

In DevTools, the same run should show exactly one red request per simulated failure and no growth in the number of pending requests over time. If the Network panel fills with hundreds of identical PATCH calls, your conflict branch is missing and the loop is fighting a stale checkpoint. Watching the same sequence from the server’s side, over the channel described in real-time upload progress events, is the fastest way to confirm that the client’s committed byte count and the server’s agree at the end.

Frequently Asked Questions

Which HTTP status codes should I retry?

Retry 500, 502, 503, 504, 408, and 429, plus any failure with no response at all. Do not retry 400, 401, 404, or 422 — those mean the request is wrong, so retrying repeats the same rejection. Treat 409, 412, 403, 413, and 507 as a third category: recoverable, but only after the client changes its offset, credential, or chunk size first.

Why add jitter instead of plain exponential backoff?

Without jitter, clients that failed together retry together, re-saturating a recovering server in synchronised waves — widening the gaps does not break the lockstep. Full jitter, a random wait between zero and the exponential bound, flattens the arrival curve and gives each client an independent chance of getting through early.

How do retries avoid duplicating data?

Key every chunk by its absolute byte offset or part number so a resent request overwrites the same range rather than appending. That makes re-delivery of an already-committed chunk a harmless no-op, which is the property that lets you retry aggressively without a checksum audit afterwards.

Should a 409 count against the retry budget?

No. A conflict is the server correcting your state, not a transient failure, and after the handshake the next request is materially different from the one that failed. Give conflicts their own small budget — three is plenty — so a server that conflicts unconditionally still terminates instead of looping.

What happens when the user goes offline mid-upload?

Gate the loop on the online event so it pauses instead of burning attempts against a dead link, and keep the committed offset persisted so a tab reload does not lose it. When connectivity returns, re-run the HEAD handshake and continue from the checkpoint — the state transitions for that pause-and-resume path are defined in resumable upload state machines.