Resuming Uploads After Network Loss

Pause the drain loop and abort the in-flight request when the offline event fires, wait out the reconnect flapping, then send a HEAD to read the server’s authoritative Upload-Offset before sending another byte β€” the client’s own counter is a cache, not the truth.

A flaky radio is the normal case for mobile uploads, and recovering from it without corrupting the file is the whole point of upload error recovery patterns inside frontend UX, chunking and progress tracking. Three mechanisms have to cooperate: a connectivity signal that tells you to stop hammering a dead link, a durable record of what you have sent β€” see persisting upload state in IndexedDB β€” and a reconciliation step that lets the server correct that record. Get the third one wrong and you ship a file with a four-megabyte hole in the middle that no checksum catches until a user complains.

When to use this approach

  • Your users upload over cellular or hotel Wi-Fi, where a transfer routinely survives three or four route changes before it finishes.
  • You chunk uploads yourself and need to restart from the exact byte the server holds, not from a local count that may be stale.
  • Your upload session outlives the request: the same uploadId must be resumable after a tab reload, a backgrounded app, or twenty minutes in a lift.

If you only need to survive a single 503 on an otherwise healthy link, you do not need any of this β€” a retry with jitter, as covered in implementing exponential backoff for failed chunks, is cheaper and simpler.

Prerequisites

  1. A chunk endpoint that answers HEAD with an Upload-Offset response header (the tus core requirement) or an equivalent JSON status route.
  2. Persisted session state β€” uploadId, resource URL, file size, chunk size, confirmed offset β€” surviving reload.
  3. A file handle you can re-slice on demand; see slicing large files with Blob.slice for why you must never buffer the whole file to do this.
  4. A chunk sender that retries transient failures on its own, so the session below only has to handle the coarse online/offline boundary.
  5. Upload-Offset added to Access-Control-Expose-Headers, or res.headers.get("Upload-Offset") returns null in the browser while looking perfect in curl.

How the browser tells you the network died

The browser exposes several connectivity signals and they answer different questions. navigator.onLine is a synchronous boolean derived from the OS routing table: Chromium returns false only when there is no route to any network at all. Aeroplane mode flips it; a base station that accepts your association and then drops every packet does not. The online and offline events on window are the push form of the same flag, and they fire on every route change β€” switching from Wi-Fi to LTE while walking out of a building typically emits offline then online inside 400 ms, sometimes twice.

None of that tells you whether your origin is reachable, and none of it tells you what the server stored. Only a request does.

What each connectivity signal actually proves A matrix comparing navigator.onLine, the online and offline events, a fetch health probe and a HEAD reconcile against three questions: does it detect a drop, does it prove the server is reachable, and does it give the byte offset. Signal Detects a drop Proves reachable Gives byte offset navigator.onLine synchronous flag, no I/O yes no no online / offline events push, fires on route change yes no no fetch probe to /healthz one round trip, ~80 ms yes yes no HEAD to Upload-Offset one round trip, authoritative yes yes yes
The cheap signals are good enough to pause on; only the round trip is good enough to resume on.

The practical rule that falls out of this table: use the events as a pause signal and a wake-up hint, never as permission to send bytes. Treat offline as authoritative in the negative direction β€” if the OS says there is no route, there is no route, so abort the socket and stop burning battery on TCP retransmits. Treat online as nothing more than β€œit is worth trying a request now”.

Where the bytes actually get lost

The dangerous moment is not the disconnect. It is the last request that crossed the gap. A PATCH or PUT can reach the server, be written to durable storage, and have its 204 response evaporate when the radio drops. The server is now four megabytes ahead of what your client believes, and the client has no way to know.

How client and server offsets diverge during a disconnect A two-lane timeline: the client PUTs bytes 8 to 12 MiB, the server stores them, the 204 response is lost while the network is down, and after reconnect a HEAD returns Upload-Offset 12 MiB so the client corrects itself. network down Client Server PUT bytes 8–12 MiB stored, offset = 12 MiB 204 lost client still at 8 MiB after online HEAD Upload-Offset: 12 MiB offset ← 12 MiB time β†’
The lost acknowledgement, not the disconnect, is what desynchronises the two offsets β€” and only the reconcile repairs it.

Which direction the correction runs depends on when you persist. If you save the offset only after a confirmed response β€” the contract this page uses β€” the server is always at or ahead of the client, and the reconcile moves the offset up, saving you a redundant 4 MiB upload on every reconnect. If you optimistically advance before confirmation, the server can be behind you and the reconcile moves the offset down; skip the reconcile there and you punch a hole in the file. There is a third case: the server is behind because it discarded your partial upload, which is what a lifecycle rule like expiring incomplete multipart uploads automatically does after a day or seven. That case is not a resume, it is a restart, and the code below detects it explicitly.

Implementation

ResumableSession owns the lifecycle: it listens for connectivity changes, aborts the in-flight request on offline, debounces the flapping that follows a reconnect, reconciles with HEAD, and only then drains the remaining slices.

export type Status = "uploading" | "paused" | "reconciling" | "complete" | "gone";

export interface SessionState {
  uploadId: string;
  url: string;        // resource URL created by the upload-creation POST
  fileSize: number;
  chunkSize: number;
  offset: number;     // last CONFIRMED byte count, never optimistic
}

export interface ChunkSource {
  /** Bytes [offset, offset + length) of the file, or null past EOF. */
  sliceAt(offset: number, length: number): Promise<Blob | null>;
  /** Durably record the new confirmed offset before the next request. */
  saveOffset(offset: number): Promise<void>;
}

export interface ResumeOptions {
  flapDebounceMs?: number;
  reconcileTimeoutMs?: number;
  maxProbeDelayMs?: number;
  onStatus?: (status: Status) => void;
}

export type PutChunk = (
  url: string,
  offset: number,
  blob: Blob,
  signal: AbortSignal,
) => Promise<void>;

/** The server no longer has this upload: restart from byte 0. */
export class UploadGone extends Error {}

export class ResumableSession {
  private running = false;
  private inflight: AbortController | null = null;
  private flapTimer: ReturnType<typeof setTimeout> | null = null;
  private probeAttempt = 0;
  private readonly flapDebounceMs: number;
  private readonly reconcileTimeoutMs: number;
  private readonly maxProbeDelayMs: number;
  private readonly onStatus: (status: Status) => void;

  constructor(
    private state: SessionState,
    private source: ChunkSource,
    private putChunk: PutChunk,
    options: ResumeOptions = {},
  ) {
    this.flapDebounceMs = options.flapDebounceMs ?? 1500;
    this.reconcileTimeoutMs = options.reconcileTimeoutMs ?? 8000;
    this.maxProbeDelayMs = options.maxProbeDelayMs ?? 30000;
    this.onStatus = options.onStatus ?? ((): void => {});
  }

  attach(): void {
    window.addEventListener("online", this.onOnline);
    window.addEventListener("offline", this.onOffline);
    if (navigator.onLine) void this.resume();
  }

  detach(): void {
    window.removeEventListener("online", this.onOnline);
    window.removeEventListener("offline", this.onOffline);
    if (this.flapTimer !== null) clearTimeout(this.flapTimer);
    this.running = false;
    this.inflight?.abort();
  }

  private onOffline = (): void => {
    this.running = false;
    this.inflight?.abort(); // free the socket now; do not wait out a 120 s TCP timeout
    this.onStatus("paused");
  };

  private onOnline = (): void => {
    // Coalesce the burst of events a Wi-Fi to LTE handover emits.
    if (this.flapTimer !== null) clearTimeout(this.flapTimer);
    this.flapTimer = setTimeout(() => {
      this.flapTimer = null;
      void this.resume();
    }, this.flapDebounceMs);
  };

  private async resume(): Promise<void> {
    if (this.running) return; // one drain at a time, whatever the event storm looks like
    this.running = true;
    this.onStatus("reconciling");
    try {
      const serverOffset = await this.discoverServerOffset();
      this.probeAttempt = 0;
      if (serverOffset !== this.state.offset) {
        this.state.offset = serverOffset;
        await this.source.saveOffset(serverOffset);
      }
      this.onStatus("uploading");
      await this.drain();
    } catch (err) {
      this.running = false;
      if (err instanceof UploadGone) {
        this.onStatus("gone");
        return;
      }
      this.scheduleProbe();
    }
  }

  /** The online event lied. Try again on a decorrelated schedule. */
  private scheduleProbe(): void {
    const ceiling = Math.min(this.maxProbeDelayMs, 1000 * 2 ** this.probeAttempt);
    this.probeAttempt += 1;
    const delay = ceiling / 2 + Math.random() * (ceiling / 2);
    setTimeout(() => {
      if (navigator.onLine) void this.resume();
    }, delay);
  }

  private async discoverServerOffset(): Promise<number> {
    const res = await fetch(this.state.url, {
      method: "HEAD",
      headers: { "Tus-Resumable": "1.0.0", "Cache-Control": "no-store" },
      signal: AbortSignal.timeout(this.reconcileTimeoutMs),
    });
    if (res.status === 404 || res.status === 410) {
      throw new UploadGone(`upload ${this.state.uploadId} is gone (HTTP ${res.status})`);
    }
    if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`);
    const raw = res.headers.get("Upload-Offset");
    const offset = Number(raw);
    if (raw === null || !Number.isInteger(offset) || offset < 0 || offset > this.state.fileSize) {
      throw new Error(`Upload-Offset malformed: ${JSON.stringify(raw)}`);
    }
    return offset;
  }

  private async drain(): Promise<void> {
    while (this.running && this.state.offset < this.state.fileSize) {
      if (!navigator.onLine) {
        this.running = false; // bail cleanly; onOnline restarts the cycle
        return;
      }
      const length = Math.min(this.state.chunkSize, this.state.fileSize - this.state.offset);
      const blob = await this.source.sliceAt(this.state.offset, length);
      if (blob === null) break;

      this.inflight = new AbortController();
      try {
        await this.putChunk(this.state.url, this.state.offset, blob, this.inflight.signal);
      } finally {
        this.inflight = null;
      }

      this.state.offset += blob.size;
      await this.source.saveOffset(this.state.offset); // persist AFTER confirmation
    }
    this.running = false;
    if (this.state.offset >= this.state.fileSize) this.onStatus("complete");
  }
}

Line-by-line of the critical parts

  • onOffline aborts the in-flight request. Without the AbortController, the pending fetch sits on a dead socket until the OS gives up β€” up to two minutes on Android β€” and its eventual rejection races the reconnect. The same pattern, with deadlines rather than connectivity as the trigger, is covered in aborting uploads with AbortController and timeouts.
  • onOnline debounces rather than resuming. A handover fires online two or three times in under a second; resuming on each one starts overlapping reconciles that all read the same offset and then all send the same chunk.
  • if (this.running) return is the second half of that defence. Debouncing handles the burst, the guard handles everything else, including a manual retry button pressed while a drain is already running.
  • sliceAt(offset, length) takes a byte offset, not a chunk index. A server offset does not have to be a multiple of your chunk size β€” some servers truncate a partial PATCH at whatever byte the connection died on. Index arithmetic silently rewinds to the start of the chunk and re-sends bytes the server already has, which a strict tus server rejects with 409 Conflict.
  • AbortSignal.timeout(reconcileTimeoutMs) caps the reconcile itself. On a captive portal the HEAD connects, then hangs; eight seconds is long enough for a slow 3G round trip and short enough that the user sees a state change.
  • 404/410 become UploadGone, a distinct type, so the UI can say β€œthis upload expired, start again” instead of retrying a resource that will never come back.
  • The Upload-Offset guard rejects nonsense. A proxy that rewrites the header, or an HTML error page returned with a 200, yields NaN; sending the next chunk at offset NaN produces a Content-Range header of bytes NaN-NaN/* and a very confusing server log.
  • saveOffset runs only after putChunk resolves. That is the invariant that keeps the correction unidirectional, and it is the same durability contract the IndexedDB store enforces.
  • putChunk is injected. Plug in the full-jitter retry sender for per-chunk transient failures, and pair it with retrying fetch uploads with idempotency keys if your endpoint is not naturally idempotent on offset.

The state machine the class implements is small enough to hold in your head, which is the point:

Resume state machine Uploading moves to Paused on the offline event; the debounced online event moves it to Reconciling, which HEADs for the server offset and returns to Uploading; when the offset reaches the file size the session is Complete. Uploading draining slices Paused queued, no sockets Reconciling HEAD β†’ Upload-Offset Complete offline online + debounce resume drain at the server offset offset == fileSize no retries fire while paused
Every path back to Uploading goes through Reconciling β€” there is no edge that resumes on the event alone.

Configuration reference

Option Type Default Effect
flapDebounceMs number 1500 Quiet period after the last online event before reconciling. Below ~800 ms you reconcile mid-handover; above ~3000 ms the UI feels stuck.
reconcileTimeoutMs number 8000 Deadline on the HEAD. Expiry throws TimeoutError and falls through to scheduleProbe.
maxProbeDelayMs number 30000 Ceiling for the reconnect probe backoff, so a laptop left on a dead network probes twice a minute rather than continuously.
state.chunkSize number 4 * 1024 * 1024 Upper bound on a single slice. Smaller chunks lose less work per drop; 1 MiB is a reasonable floor on cellular.
onStatus callback no-op Receives paused, reconciling, uploading, complete, gone β€” wire it to the UI so a stalled upload never looks identical to a running one.

The reconcile has to interpret whatever the server returns, and the four interesting responses need genuinely different handling:

Response to HEAD Meaning Action
200 + Upload-Offset: N Server holds N bytes Adopt N, drain from there
403 Signature or session token expired Re-issue the upload URL, keep the offset
404 / 410 Session expired or was reaped Throw UploadGone, restart at byte 0
5xx or network error Origin unreachable despite navigator.onLine scheduleProbe, stay reconciling

Configuration gotchas

Symptom: the drain loop runs, each putChunk rejects with TypeError: Failed to fetch, and the retry sender burns through its budget in twelve seconds. Cause: a captive portal, or a base station that associated but has no backhaul. Fix: never let the online event start a drain directly. The reconcile in front of it fails first and hands control to scheduleProbe, which retries on a decorrelated schedule instead of a tight loop.

409 Conflict on the first chunk after reconnect

Symptom: HTTP 409 with a body like Upload-Offset does not match the current offset. Cause: you resumed from a chunk boundary rather than from the byte the server reported, usually because the offset arithmetic went through a chunk index. Fix: slice from state.offset exactly, as drain does. If you see this only on the final chunk, check that your last length is clamped to fileSize - offset and not the full chunkSize.

Upload-Offset reads as null in the browser

Symptom: res.headers.get("Upload-Offset") is null while curl -I clearly shows the header. Cause: cross-origin responses expose only the CORS-safelisted headers by default. Fix: add Access-Control-Expose-Headers: Upload-Offset, Tus-Resumable to the response, and remember the preflight must allow HEAD in Access-Control-Allow-Methods β€” the details are in fixing CORS preflight errors on S3 uploads.

The resumed chunk gets rejected with 413

Symptom: the upload runs fine on Wi-Fi, then a resumed request returns 413 Content Too Large. Cause: after a rewind you sent a larger-than-usual body, or the resume path routes through a different proxy with a lower client_max_body_size. Fix: cap the slice, and handle the status explicitly rather than retrying it β€” see handling 413 and 507 errors during uploads.

Verification

First confirm the endpoint’s half of the contract from a shell, including the exposed header:

curl -sI -X HEAD -H 'Tus-Resumable: 1.0.0' https://api.example.com/uploads/u_test \
  | grep -iE 'upload-offset|access-control-expose-headers'
# upload-offset: 8388608
# access-control-expose-headers: Upload-Offset, Tus-Resumable

Then drive the client through a real disconnect. In DevTools set the network profile to Offline, watch for the paused status, switch back to No throttling, and assert that the session adopts the server’s offset instead of its own:

const MIB = 1024 * 1024;
const file = new Blob([new Uint8Array(12 * MIB)]);
const sent = [];

const state = {
  uploadId: "u_test",
  url: "/uploads/u_test",
  fileSize: file.size,
  chunkSize: 4 * MIB,
  offset: 4 * MIB, // client believes it has confirmed only 4 MiB
};

const source = {
  async sliceAt(offset, length) {
    return offset >= file.size ? null : file.slice(offset, offset + length);
  },
  async saveOffset(next) {
    localStorage.setItem("u_test.offset", String(next));
  },
};

const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) =>
  init && init.method === "HEAD"
    ? new Response(null, { status: 200, headers: { "Upload-Offset": String(8 * MIB) } })
    : realFetch(input, init);

const session = new ResumableSession(
  state,
  source,
  async (_url, offset) => { sent.push(offset); },
  { flapDebounceMs: 0, onStatus: (s) => console.info("[resume]", s) },
);

session.attach();
window.dispatchEvent(new Event("offline"));
window.dispatchEvent(new Event("online"));
await new Promise((resolve) => setTimeout(resolve, 100));

console.assert(sent[0] === 8 * MIB, `first PUT should start at 8 MiB, got ${sent[0]}`);
console.assert(state.offset === file.size, "session should finish at EOF");
console.assert(localStorage.getItem("u_test.offset") === String(file.size), "offset persisted");

globalThis.fetch = realFetch;
session.detach();

Three things prove the mechanism works: the first resumed request starts at 8388608 and not at 4194304, the status callback emits paused before reconciling, and exactly one drain runs no matter how many online events you dispatch. If you also want the file’s integrity checked end to end, compare a client-side digest against the stored object using computing file checksums in the browser with Web Crypto.

Frequently Asked Questions

Why HEAD instead of trusting the offset I persisted locally?

Persistence tells you what your client observed, not what the server committed; the gap between those two is exactly one lost response. A HEAD costs a single round trip β€” typically 60–200 ms β€” and removes an entire class of silent corruption, so there is no realistic budget in which skipping it pays.

Should I keep uploading in a background tab after reconnect?

Yes, but expect throttling: background tabs get timers clamped to roughly once per minute in Chromium, so a debounce of 1500 ms can become a 60 s delay. Drive the drain from request completions rather than intervals, as the loop above does, and it keeps progressing whenever the tab is allowed to run at all.

How do I show sensible progress while the session is paused?

Freeze the throughput estimate rather than letting it decay towards zero, and label the state explicitly β€” a stalled bar with no explanation reads as a crash. The estimator behaviour is covered in showing accurate time-remaining estimates.

What if the user closes the tab in the middle of a disconnect?

Nothing is lost, provided the offset was written durably before the request. On the next visit, rehydrate SessionState from storage, call attach(), and the constructor path reconciles before sending β€” the resume after a tab close and the resume after a radio drop are the same code path.

How does this relate to the tus protocol?

tus standardises this exact handshake: HEAD returns Upload-Offset, and PATCH with a matching Upload-Offset appends. Adopting it means the reconcile is already implemented on both ends β€” see building a resumable upload flow with tus β€” while the code here is what you write when the chunking protocol is your own.