Resumable Upload State Machines

A long-running upload has more states than a boolean can hold, and the moment two implicit flags disagree — paused and uploading — you get a frozen bar or a double-sent chunk. Modeling the transfer as an explicit finite state machine with a single source of truth for the committed offset eliminates those impossible states and makes resume-after-failure a first-class operation rather than an afterthought. This topic sits under Frontend UX, Chunking & Progress Tracking and pairs directly with upload error recovery patterns, which drives the transitions into and out of the retrying state.

Prerequisites

  • [ ] Node 20+ and a bundler that supports modern ESM
  • [ ] TypeScript 5.x with strict enabled
  • [ ] A chunked or tus-compatible upload endpoint that accepts Range/Upload-Offset
  • [ ] Browser support for IndexedDB (every evergreen browser qualifies)
  • [ ] A stable file fingerprint (size + last-modified + name, or a content hash)
  • [ ] A CORS policy that exposes the offset header to script — see fixing CORS preflight errors on S3 uploads

How a resumable upload machine works

The machine has six states. idle is the resting state before a file is chosen. uploading is the active transfer; each confirmed chunk advances a durable offset. paused is a user-initiated halt that keeps the offset intact. retrying is entered automatically on a recoverable error and exits back to uploading after a backoff delay. completed and failed are terminal, except that failed permits a manual restart.

The offset is the keystone. It is not the number of bytes you have sent — it is the number of bytes the server has committed, learned from acknowledgements and confirmed by the resume handshake. Persisting { state, offset, uploadId, fingerprint, chunkSize } after every confirmed chunk means a crashed or reloaded tab can reconstruct exactly where it stood.

Upload finite state machine States idle, uploading, paused, retrying, completed and failed, with labelled transitions for start, pause, resume, chunk acknowledgement, recoverable error, fatal error, retry and manual restart. idle START uploading offset += chunk paused PAUSE RESUME retrying backoff ERROR RETRY ALL_DONE completed fatal ERROR failed START again (manual restart from failed)
The six-state upload machine: confirmed chunks advance the offset in uploading; recoverable errors detour through retrying; only a manual restart escapes failed. Self-edges (CHUNK_OK, SYNC_OFFSET) are omitted for clarity.

Six states and the invariants they protect

Every state exists to make one class of bug unrepresentable. idle guarantees no network handle is open, so a file swap cannot race an in-flight PATCH. uploading is the only state permitted to hold an AbortController that is not yet aborted, which gives you exactly one place to cancel. paused guarantees the retry budget is frozen: a user who pauses over lunch should not come back to a failed upload because six backoff timers fired in their absence. retrying carries an attempt counter that resets to zero on the next CHUNK_OK, so a flaky network that recovers does not slowly exhaust the budget across an hour-long transfer.

The two terminal states are deliberately asymmetric. completed accepts no events at all — a late acknowledgement arriving after the final part is dropped on the floor rather than pushing the offset past total. failed accepts START, and only START, which forces a full handshake before a single byte moves again. That asymmetry is what stops a “retry” button from re-sending bytes the server already has.

The practical test for whether your state set is right: write down every pair of boolean flags you would otherwise keep in component state (isUploading, isPaused, hasError, isDone) and count the combinations. Four flags give sixteen combinations, of which six are legal. A finite state machine encodes the six and makes the other ten unreachable by construction, which is a stronger guarantee than any amount of defensive if nesting.

Three counters, one durable truth

At any instant a chunked upload has three different byte counts, and confusing them is the single most common source of “the bar reached 100% but the file is corrupt” reports. The first is sliced and sent: bytes the browser has handed to the network stack via Blob.slice and a request body. The second is acknowledged: bytes for which a 2xx response has come back. The third is persisted: the offset you have actually written to IndexedDB and would recover after a crash.

Sliced, acknowledged and persisted byte counters Three horizontal bars on a 100 megabyte scale showing 60 megabytes sliced and sent, 55 megabytes acknowledged by the server and 50 megabytes persisted to IndexedDB, with a 10 megabyte at-risk band between the persisted offset and the sent frontier. Three counters, mid-transfer on a 100 MB file sliced and sent 60 MB server ACKed 55 MB persisted offset 50 MB 10 MB at risk 0 100 MB
Only the bottom bar survives a crash. The gap between it and the sent frontier is exactly the work you will redo, which is why persistence cadence is a cost knob, not a correctness knob.

The gap between the top and bottom bars is your redo cost. Persisting after every chunk makes it at most one chunk; persisting on a one-second timer makes it one second of throughput, which on a 40 Mbps link is roughly 5 MB. Neither choice can make the upload wrong, because the handshake reconciles against the server on resume — it only changes how much bandwidth you burn a second time. Progress UI should always read the acknowledged counter, never the sent counter, otherwise the bar runs ahead and then appears to stall; real-time upload progress events covers how to smooth that reading without lying about it.

Effects belong outside the reducer

The reducer must stay pure: context in, context out, no fetch, no setTimeout, no indexedDB. Purity is what lets you replay a recorded event log in a test and assert the final offset in microseconds. Effects — writing to IndexedDB, scheduling a backoff timer, aborting a request — hang off a subscriber that observes transitions and reacts to entering a state, not to a raw event.

That split has a subtle consequence worth internalising early. Because a subscriber can dispatch (a retry timer eventually fires RETRY_NOW; an exhausted budget fires a fatal ERROR), you must never dispatch synchronously from inside a listener while the listener set is still being iterated. Wrap the re-entrant dispatch in queueMicrotask and the store stays a simple synchronous function instead of needing a queue.

Sequential offsets versus parallel parts

A single scalar offset is only correct when chunks are strictly ordered, which is what the tus protocol enforces — a PATCH at the wrong Upload-Offset returns 409 Conflict. S3 multipart is the opposite: parts may be uploaded in any order and in parallel, and the “how much is done” question is answered by a set of completed part numbers plus their ETags. Running four lanes at once against S3 typically gets you 2.5–3× the throughput of a single lane on a residential connection, so the parallelism is worth the extra bookkeeping.

If you go parallel, keep the scalar in the context but derive it as a contiguous high-water mark: sort the completed part numbers, walk from part 1, and stop at the first gap. Parts 1, 2, 3 and 7 complete means offset = 3 * partSize, not 4 * partSize. Resuming from the high-water mark re-sends part 7 — wasteful but always correct — whereas resuming from the count silently skips parts 4 through 6. The safer alternative is to persist the full part map and let the resume path re-send only the gaps; the S3 presigned URL workflows guide covers how to re-issue signatures for just those parts.

Step-by-step implementation

Step 1: Define states, events, and the transition table

Encode the machine as data so it can be tested, rendered, and serialized. The transition table is the contract; the reducer never invents an edge that is not in the table. Note the SYNC_OFFSET self-edge — the handshake needs a legal way to overwrite the offset without changing state.

export type UploadState =
  | "idle" | "uploading" | "paused" | "retrying" | "completed" | "failed";

export type UploadEvent =
  | { type: "START" }
  | { type: "PAUSE" }
  | { type: "RESUME" }
  | { type: "SYNC_OFFSET"; offset: number }
  | { type: "CHUNK_OK"; bytes: number }
  | { type: "ERROR"; fatal: boolean; status?: number }
  | { type: "RETRY_NOW" }
  | { type: "ALL_DONE" };

export interface UploadContext {
  state: UploadState;
  offset: number;      // bytes the server has committed
  total: number;       // file size in bytes
  uploadId: string;    // opaque server handle or tus Location URL
  fingerprint: string; // IndexedDB primary key
  chunkSize: number;
  attempt: number;     // consecutive failures since the last CHUNK_OK
  updatedAt: number;   // epoch ms, used to expire abandoned sessions
}

export type TransitionTable = Record<
  UploadState,
  Partial<Record<UploadEvent["type"], UploadState>>
>;

export const TRANSITIONS: TransitionTable = {
  idle:      { START: "uploading", SYNC_OFFSET: "idle" },
  uploading: {
    CHUNK_OK: "uploading", SYNC_OFFSET: "uploading",
    PAUSE: "paused", ERROR: "retrying", ALL_DONE: "completed",
  },
  paused:    { RESUME: "uploading", SYNC_OFFSET: "paused" },
  retrying:  {
    RETRY_NOW: "uploading", SYNC_OFFSET: "retrying",
    PAUSE: "paused", ERROR: "failed",
  },
  completed: {},
  failed:    { START: "uploading" },
};

Expected: TRANSITIONS.uploading.PAUSE returns "paused"; TRANSITIONS.completed.START is undefined, which the reducer treats as a no-op.

Step 2: Write a pure reducer that updates the offset

The reducer is the only place state changes. It applies the table, special-cases fatal errors, and advances the committed offset only on CHUNK_OK or an explicit SYNC_OFFSET — never on dispatch alone. Returning the same object reference for an ignored event is deliberate: the store uses reference equality to decide whether to notify subscribers.

import { TRANSITIONS, type UploadContext, type UploadEvent } from "./machine.js";

export function reduce(ctx: UploadContext, event: UploadEvent): UploadContext {
  if (event.type === "ERROR" && event.fatal) {
    return { ...ctx, state: "failed", updatedAt: Date.now() };
  }

  const target = TRANSITIONS[ctx.state][event.type];
  if (!target) {
    console.warn(`upload: no transition for ${event.type} in ${ctx.state}`);
    return ctx; // illegal edge is ignored, not thrown
  }

  switch (event.type) {
    case "CHUNK_OK":
      return {
        ...ctx,
        state: target,
        attempt: 0,
        offset: Math.min(ctx.total, ctx.offset + event.bytes),
        updatedAt: Date.now(),
      };
    case "SYNC_OFFSET":
      return {
        ...ctx,
        state: target,
        offset: Math.max(0, Math.min(ctx.total, event.offset)),
        updatedAt: Date.now(),
      };
    case "ERROR":
      return { ...ctx, state: target, attempt: ctx.attempt + 1, updatedAt: Date.now() };
    case "START":
      return { ...ctx, state: target, attempt: 0, updatedAt: Date.now() };
    default:
      return { ...ctx, state: target, updatedAt: Date.now() };
  }
}

Expected console output when dispatching RESUME while idle: upload: no transition for RESUME in idle, and ctx comes back unchanged and reference-identical.

Step 3: Wrap the reducer in a store with subscribers

The store is thirty lines and replaces whatever observable library you were reaching for. It holds the current context, applies the reducer, and notifies listeners with both the new context and the previous state so they can react to entering a state rather than to every event.

import { reduce } from "./reducer.js";
import { type UploadContext, type UploadEvent, type UploadState } from "./machine.js";

export type Listener = (ctx: UploadContext, prev: UploadState) => void;

export interface UploadStore {
  get(): UploadContext;
  dispatch(event: UploadEvent): UploadContext;
  subscribe(fn: Listener): () => void;
}

export function createStore(initial: UploadContext): UploadStore {
  let ctx = initial;
  const listeners = new Set<Listener>();

  return {
    get: () => ctx,
    dispatch(event: UploadEvent): UploadContext {
      const prev = ctx.state;
      const next = reduce(ctx, event);
      if (next === ctx) return ctx; // ignored edge: no notification
      ctx = next;
      for (const fn of [...listeners]) fn(ctx, prev);
      return ctx;
    },
    subscribe(fn: Listener): () => void {
      listeners.add(fn);
      return () => {
        listeners.delete(fn);
      };
    },
  };
}

Copying the listener set with [...listeners] before iterating means a subscriber that unsubscribes itself — a one-shot “upload finished” toast, say — cannot corrupt the iteration.

Step 4: Persist the context after every confirmed chunk

Durable state is what makes the machine resumable. Write the whole context after each CHUNK_OK so a reload reconstructs the exact offset, and delete the record on completed so the store does not accumulate dead sessions. A thin promise wrapper over IndexedDB keeps the call sites readable; the companion guide on persisting upload state in IndexedDB covers schema versioning and migrations in depth.

import { type UploadContext } from "./machine.js";

const DB = "uploads", STORE = "sessions", VERSION = 1;

function open(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB, VERSION);
    req.onupgradeneeded = () => {
      const store = req.result.createObjectStore(STORE, { keyPath: "fingerprint" });
      store.createIndex("updatedAt", "updatedAt");
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function run<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise<T> {
  return open().then((db) =>
    new Promise<T>((resolve, reject) => {
      const tx = db.transaction(STORE, mode);
      const req = fn(tx.objectStore(STORE));
      tx.oncomplete = () => { db.close(); resolve(req.result as T); };
      tx.onerror = () => { db.close(); reject(tx.error); };
    }),
  );
}

export function saveSession(ctx: UploadContext): Promise<IDBValidKey> {
  return run<IDBValidKey>("readwrite", (s) => s.put(ctx));
}

export function loadSession(fingerprint: string): Promise<UploadContext | undefined> {
  return run<UploadContext | undefined>("readonly", (s) => s.get(fingerprint));
}

export function clearSession(fingerprint: string): Promise<undefined> {
  return run<undefined>("readwrite", (s) => s.delete(fingerprint));
}

Expected: after one confirmed 5 MB chunk, loadSession(fingerprint) resolves to a context with offset === 5_242_880 and state === "uploading". In DevTools the record appears under Application → Storage → IndexedDB → uploads → sessions, keyed by the fingerprint string.

Step 5: Perform the resume handshake before sending bytes

On reload you must not trust the persisted offset blindly. The server may have garbage-collected an incomplete upload, or — more often than teams expect — committed more than the client recorded, because the last acknowledgement was lost in transit while the write had already landed. Ask the server. For a tus endpoint, a HEAD returns the authoritative Upload-Offset; for S3 multipart, call your own API which proxies ListParts and returns the completed part numbers.

Crash and resume handshake sequence A sequence diagram with three lifelines — IndexedDB, the upload machine and the server — showing the persisted offset being written, the tab crashing and reloading, the hint being read back, a HEAD request returning a larger authoritative offset, and the next PATCH starting from the server value. IndexedDB upload machine server put { offset: 52428800 } tab crashes, reloads get(fingerprint) offset 52428800 (hint) HEAD /uploads/abc123 Upload-Offset: 53477376 PATCH at byte 53477376 server offset wins; the 1 MB hint gap is discarded
The persisted offset is only a hint. Here the server had committed 1 MB more than the client recorded, and taking the server value avoids re-sending a chunk that already landed.
export interface ResumePoint {
  offset: number;
  restart: boolean;
}

export async function resumeHandshake(
  uploadUrl: string,
  signal?: AbortSignal,
): Promise<ResumePoint> {
  const res = await fetch(uploadUrl, {
    method: "HEAD",
    headers: { "Tus-Resumable": "1.0.0", "Cache-Control": "no-store" },
    signal,
  });

  // The server garbage-collected the partial upload: start clean.
  if (res.status === 404 || res.status === 410) {
    return { offset: 0, restart: true };
  }
  if (res.status === 403) {
    throw new Error("HANDSHAKE_FORBIDDEN: upload token expired, re-issue it");
  }
  if (!res.ok) {
    throw new Error(`Handshake failed: HTTP ${res.status}`);
  }

  const raw = res.headers.get("Upload-Offset");
  const offset = Number(raw);
  if (raw === null || !Number.isInteger(offset) || offset < 0) {
    throw new Error(`Invalid Upload-Offset from server: ${String(raw)}`);
  }
  return { offset, restart: false };
}

Expected: a partially uploaded session returns { offset: 53477376, restart: false }; a server-side 404 or 410 returns { offset: 0, restart: true }, which the machine maps to a START-from-zero flow. If raw is null on a cross-origin endpoint that clearly sent the header, the cause is almost always a missing Access-Control-Expose-Headers: Upload-Offset — the browser strips it silently.

Step 6: Build a transport that verifies the committed offset

The transport is the only module that touches the network. Making it verify the server’s echoed offset after every write turns a whole class of silent corruption into a loud 409. Bind the caller’s AbortSignal so pause and cancel are instantaneous rather than “after this 8 MB chunk finishes”; the mechanics of that are covered in aborting uploads with AbortController and timeouts.

export class HttpError extends Error {
  readonly status: number;
  constructor(status: number, message: string) {
    super(message);
    this.name = "HttpError";
    this.status = status;
  }
}

export interface Transport {
  send(blob: Blob, offset: number, signal: AbortSignal): Promise<void>;
}

export function createTusTransport(uploadUrl: string): Transport {
  return {
    async send(blob: Blob, offset: number, signal: AbortSignal): Promise<void> {
      const res = await fetch(uploadUrl, {
        method: "PATCH",
        headers: {
          "Tus-Resumable": "1.0.0",
          "Upload-Offset": String(offset),
          "Content-Type": "application/offset+octet-stream",
        },
        body: blob,
        signal,
      });

      if (!res.ok) {
        throw new HttpError(res.status, `PATCH at ${offset} failed: HTTP ${res.status}`);
      }

      const committed = Number(res.headers.get("Upload-Offset"));
      const expected = offset + blob.size;
      if (committed !== expected) {
        throw new HttpError(
          409,
          `Offset drift: server committed ${committed}, expected ${expected}`,
        );
      }
    },
  };
}

Expected on a healthy write of an 8 MB chunk at offset 0: HTTP/1.1 204 No Content with Upload-Offset: 8388608, and send resolves. Expected when a proxy truncated the body: HttpError: Offset drift: server committed 8126464, expected 8388608, which the loop classifies as recoverable and the handshake corrects on the next attempt.

Step 7: Drive the chunk loop from the reconciled offset

With the authoritative offset in hand, slice from there and feed each chunk through the loop, dispatching CHUNK_OK on success and ERROR on failure so the machine — and the persisted offset — stay in lockstep. Classify the failure at this boundary: a 4xx that will never succeed on retry is fatal, everything else routes through retrying.

import { type UploadContext, type UploadEvent } from "./machine.js";
import { resumeHandshake } from "./handshake.js";
import { HttpError, type Transport } from "./transport.js";

const FATAL_STATUS = new Set([400, 401, 403, 404, 409, 413, 415, 422]);

function isFatal(err: unknown): boolean {
  return err instanceof HttpError && FATAL_STATUS.has(err.status);
}

export async function runLoop(
  file: File,
  uploadUrl: string,
  store: { get(): UploadContext; dispatch(e: UploadEvent): UploadContext },
  transport: Transport,
  controller: AbortController,
): Promise<void> {
  const point = await resumeHandshake(uploadUrl, controller.signal);
  if (point.restart) {
    throw new Error("UPLOAD_EXPIRED: create a fresh upload and start from zero");
  }

  let ctx = store.dispatch({ type: "SYNC_OFFSET", offset: point.offset });
  if (ctx.state !== "uploading") {
    ctx = store.dispatch({ type: ctx.state === "paused" ? "RESUME" : "START" });
  }

  while (ctx.offset < file.size && ctx.state === "uploading") {
    const end = Math.min(file.size, ctx.offset + ctx.chunkSize);
    const blob = file.slice(ctx.offset, end);
    try {
      await transport.send(blob, ctx.offset, controller.signal);
      ctx = store.dispatch({ type: "CHUNK_OK", bytes: blob.size });
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") return; // paused
      const status = err instanceof HttpError ? err.status : 0;
      store.dispatch({ type: "ERROR", fatal: isFatal(err), status });
      return; // hand control to the retry scheduler
    }
  }

  if (ctx.offset >= file.size) store.dispatch({ type: "ALL_DONE" });
}

Expected: on a resumed upload the first file.slice starts at the reconciled offset, so already-committed bytes are never re-sent. A 413 from an intermediary returns immediately with state === "failed" rather than burning six retries — see handling 413 and 507 errors during uploads for how to shrink chunkSize and restart instead of giving up.

Step 8: Wire persistence, backoff and the pause control

The last piece is the effect layer. One subscriber handles all of it: persist on every meaningful transition, clear the record on completion, and schedule a jittered retry when the machine enters retrying. The backoff formula itself is derived in implementing exponential backoff for failed chunks.

import { createStore, type UploadStore } from "./store.js";
import { saveSession, clearSession } from "./persistence.js";
import { createTusTransport } from "./transport.js";
import { runLoop } from "./loop.js";
import { type UploadContext } from "./machine.js";

const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 30_000;
const MAX_ATTEMPTS = 6;

export interface UploadHandle {
  store: UploadStore;
  pause(): void;
  resume(): void;
}

export function startUpload(
  file: File,
  uploadUrl: string,
  initial: UploadContext,
): UploadHandle {
  const store = createStore(initial);
  const transport = createTusTransport(uploadUrl);
  let controller = new AbortController();
  let timer = 0;

  const drive = () => {
    void runLoop(file, uploadUrl, store, transport, controller).catch((err: unknown) => {
      queueMicrotask(() => store.dispatch({ type: "ERROR", fatal: true }));
      console.error("upload aborted:", err);
    });
  };

  store.subscribe((ctx, prev) => {
    if (ctx.state !== "completed") void saveSession(ctx);
    if (ctx.state === "completed") void clearSession(ctx.fingerprint);

    if (ctx.state === "retrying" && prev !== "retrying") {
      if (ctx.attempt >= MAX_ATTEMPTS) {
        queueMicrotask(() => store.dispatch({ type: "ERROR", fatal: true }));
        return;
      }
      const cap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** ctx.attempt);
      timer = self.setTimeout(() => {
        controller = new AbortController();
        store.dispatch({ type: "RETRY_NOW" });
        drive();
      }, Math.random() * cap);
    }
  });

  drive();

  return {
    store,
    pause() {
      self.clearTimeout(timer);
      controller.abort();
      store.dispatch({ type: "PAUSE" });
    },
    resume() {
      controller = new AbortController();
      store.dispatch({ type: "RESUME" });
      drive();
    },
  };
}

Expected log sequence on a network drop at 55 MB of a 100 MB file, with the link restored eight seconds later: upload: no transition for CHUNK_OK in retrying (the in-flight chunk resolving late), then a HEAD showing Upload-Offset: 57671680, then PATCH resuming from that byte. Total re-sent data: one chunk.

Configuration reference

Option Type Default Effect
chunkSize number (bytes) 5_242_880 Slice size; must be ≥ 5 MB for S3 multipart non-final parts, and ≤ your proxy’s body limit
fingerprint string derived IndexedDB primary key identifying the file across sessions
total number (bytes) file.size Upper bound the reducer clamps offset against
maxAttempts number 6 Consecutive failures before retrying transitions to failed
baseDelayMs number 500 First backoff interval; doubles per attempt
maxDelayMs number 30_000 Ceiling on the backoff window, jitter applied inside it
handshakeMethod "HEAD" | "GET" "HEAD" Verb used to read the committed offset
persistOn "chunk" | "interval" "chunk" When the context is written to IndexedDB
persistIntervalMs number 1000 Debounce window when persistOn is "interval"
sessionTtlMs number 86_400_000 Age at which a stored session is swept, matched to the server’s own expiry
lanes number 1 Concurrent chunk requests; > 1 requires a part map instead of a scalar offset
verifyEchoedOffset boolean true Throw 409 when the server’s echoed Upload-Offset disagrees with offset + blob.size

Set sessionTtlMs to the same value as the bucket rule that expires incomplete multipart uploads; keeping the two in sync is the difference between a clean restart and a confusing 404 on resume, and expiring incomplete multipart uploads automatically shows the bucket side of that contract.

Edge cases and gotchas

Stale offset after server-side garbage collection

If the server expires incomplete uploads after 24 hours, the persisted offset becomes a lie. Always run the handshake before resuming and map a 404 or 410 to a clean restart, rather than letting range requests fail one by one and burn the retry budget on an upload that no longer exists. Sweep your own IndexedDB store on startup using the updatedAt index: anything older than sessionTtlMs is dead weight that will only produce a failed handshake later.

Fingerprint collisions and the cost of hashing

Using only file.name as the key collides when a user re-uploads a different file with the same name — extremely common with IMG_0001.jpg from a camera roll. Combine name, size and lastModified for a cheap key that is right in nearly every case. For high-stakes uploads compute a content hash instead, so a changed file gets a new session rather than resuming onto stale bytes; computing file checksums in the browser with Web Crypto shows how to do it incrementally so a 2 GB file does not lock the main thread for thirty seconds.

Re-entrant dispatch inside a subscriber

A subscriber that dispatches synchronously re-enters dispatch while the outer notification loop is still running, and listeners then observe transitions out of order — the retry timer sees failed before the UI has rendered retrying. Defer with queueMicrotask, as the effect layer in step 8 does. The symptom when you get this wrong is a progress bar that flickers to an error state and back, because two listeners disagreed about the current context for one frame.

IndexedDB write latency and the quota ceiling

Writing the full context on every chunk costs roughly 1–3 ms on desktop and up to 20 ms on a low-end Android device under storage pressure. If you see contention, switch persistOn to "interval" and accept that a crash may cost one second of re-upload. Watch for QuotaExceededError too: the origin’s storage budget is shared with caches and the object store will start rejecting writes long before the disk is full. Catch it, drop to in-memory state, and let the handshake do the recovery instead of failing the upload.

Late acknowledgements after a pause

A CHUNK_OK can arrive after the user hits pause, because the request was already in flight when abort() landed. Because the reducer ignores undefined edges, the stray event is dropped instead of corrupting state — but make sure your UI reads state from the store, not from a separate flag that could disagree. The dropped acknowledgement is not lost work: the server still committed those bytes, and the next handshake picks them up.

The offset header hidden by CORS

Cross-origin HEAD responses expose only the safelisted headers unless the server sends Access-Control-Expose-Headers. Without Upload-Offset in that list, res.headers.get("Upload-Offset") returns null even though the header is visible in the DevTools Network panel — the panel shows the wire, the JavaScript sees the filtered view. The same trap catches Location on the initial POST that creates a tus upload.

Offset drift when an intermediary buffers the body

Some reverse proxies buffer and re-chunk request bodies, and a truncated write can produce a 2xx with a shorter committed offset. That is exactly what verifyEchoedOffset catches. Treat the resulting synthetic 409 as recoverable, force a fresh handshake, and resume from whatever the server actually holds. If it recurs on every chunk, the body size exceeds a limit somewhere in the chain rather than being a transient fault.

Two tabs, one file

Nothing stops a user from opening the same file in two tabs, and both machines will happily PATCH the same upload URL, producing alternating 409 Conflict responses that look like server flakiness. Guard the session by taking a Web Lock named after the fingerprint with navigator.locks.request before the loop starts, and let the loser render a read-only progress view fed by the shared IndexedDB record. This is the same coordination problem as resuming uploads after network loss, just with a second tab standing in for the network.

Retries that are not idempotent

A chunk retry is only safe when the server treats a repeated write at the same offset as a no-op. Offset-addressed protocols get this for free; endpoints that append blindly do not, and a retried chunk silently duplicates bytes. If your API is append-only, attach an idempotency key per chunk as described in retrying fetch uploads with idempotency keys.

Verification

Three checks prove the machine works: the table has no unreachable states, the reducer moves the offset only when it should, and the server agrees with the client about where to resume.

Start with the server, because it is the fastest to falsify:

# Ask the server for the authoritative committed offset.
curl -sI https://api.example.com/uploads/abc123 \
  -H 'Tus-Resumable: 1.0.0' | grep -i 'upload-offset'
# => Upload-Offset: 53477376

# Confirm the header is actually exposed to cross-origin script.
curl -sI https://api.example.com/uploads/abc123 \
  -H 'Origin: https://app.example.com' | grep -i 'access-control-expose-headers'
# => Access-Control-Expose-Headers: Upload-Offset, Upload-Length, Location

Then audit the transition table itself. Every state must be reachable from idle, or you have written an edge you can never exercise:

import { TRANSITIONS, type UploadState } from "./machine.js";

const states = Object.keys(TRANSITIONS) as UploadState[];
const reachable = new Set<UploadState>(["idle"]);
let grew = true;

while (grew) {
  grew = false;
  for (const from of [...reachable]) {
    for (const to of Object.values(TRANSITIONS[from])) {
      if (to && !reachable.has(to)) {
        reachable.add(to);
        grew = true;
      }
    }
  }
}

const orphans = states.filter((s) => !reachable.has(s));
console.assert(orphans.length === 0, `unreachable states: ${orphans.join(", ")}`);
console.log(`reachable: ${[...reachable].join(", ")}`);
// => reachable: idle, uploading, paused, retrying, completed, failed

Finally, assert the reducer’s offset arithmetic and the terminal guarantees:

import { reduce } from "./reducer.js";
import { type UploadContext } from "./machine.js";

const base: UploadContext = {
  state: "uploading", offset: 0, total: 10, uploadId: "abc123",
  fingerprint: "f", chunkSize: 5, attempt: 0, updatedAt: 0,
};

const after = reduce(base, { type: "CHUNK_OK", bytes: 5 });
console.assert(after.offset === 5, "offset must advance on CHUNK_OK");

const clamped = reduce(after, { type: "CHUNK_OK", bytes: 99 });
console.assert(clamped.offset === 10, "offset must clamp at total");

const done = reduce(clamped, { type: "ALL_DONE" });
console.assert(done.state === "completed", "ALL_DONE is terminal");
console.assert(reduce(done, { type: "CHUNK_OK", bytes: 5 }) === done,
  "completed must ignore late acknowledgements by reference");
console.assert(reduce(base, { type: "ERROR", fatal: true }).state === "failed",
  "fatal error must be terminal");
console.log("reducer invariants hold");

In the browser, the end-to-end proof is a DevTools exercise: start a 100 MB upload, throttle to Offline at around 40%, watch the machine enter retrying and the progress bar hold steady, restore the connection, and confirm in the Network panel that the first request after recovery is a HEAD and the following PATCH starts at the offset that HEAD reported. Then hard-reload the tab mid-transfer and confirm the same sequence replays from the IndexedDB record — that is the whole feature, demonstrated in about ninety seconds. Pair it with a stopwatch reading from showing accurate time-remaining estimates to check the estimate recovers rather than resetting to infinity.

Frequently Asked Questions

Why an explicit state machine instead of a few boolean flags?

Boolean flags allow combinations that should be impossible — isPaused && isUploading — and every such combination is a latent bug. A finite state machine enumerates only the legal states and edges, so illegal transitions become no-ops you can log instead of crashes you have to debug.

Where exactly should the offset come from — the client or the server?

The server. The client tracks what it sent, but only the server knows what it committed. Treat the persisted client offset as a hint and reconcile it with a handshake (HEAD for tus Upload-Offset, or a committed-range query) before sending any bytes.

How does pause differ from a failure-driven retry?

paused is user-intent and stays put indefinitely with the offset frozen; retrying is automatic, time-boxed by backoff, and managed alongside upload error recovery patterns. Keeping them as distinct states means a paused upload never silently consumes the retry budget.

Can I show progress while in the retrying state?

Yes — read the same committed offset the machine persists and surface it through real-time upload progress events. The bar should hold steady at the last committed percentage during backoff rather than resetting, which reassures users that progress is preserved.

Do I need a state machine library like XState for this?

No. The transition table plus the reducer in steps 1 and 2 is about 70 lines and has no dependencies, which matters when it ships in an upload widget embedded on someone else’s page. Reach for a library when you need hierarchical states, parallel regions or a visual editor across many machines; for a single upload with six states, the hand-rolled table is easier to test and easier to serialise into IndexedDB. If you would rather not own any of it, building a resumable upload flow with tus uses a client that already implements the machine internally.