Resuming Uploads After Network Loss

Pause the upload when navigator.onLine goes false, queue work until the online event fires, then send a HEAD request to discover the server’s true offset before resuming — so you never re-send confirmed bytes.

A flaky connection is the normal case for mobile uploads, and recovering from it cleanly is the goal of upload error recovery patterns inside frontend UX, chunking and progress tracking. Two pieces make resume reliable: connectivity awareness (navigator.onLine plus the online/offline events) to stop hammering a dead network, and a server-truth reconciliation step (HEAD) so the client trusts the server’s offset over its own optimistic state. This guide combines both with a resume queue, building on the durable state from persisting upload state in IndexedDB.

When to use this approach

  • Your users upload over mobile or unstable Wi-Fi where the connection drops mid-transfer.
  • You chunk uploads and need to resume from the exact byte the server already holds, not from a possibly-wrong local count.
  • You want to pause cleanly while offline and auto-resume the instant connectivity returns.

Prerequisites

  1. A chunk endpoint that supports a HEAD request returning the current Upload-Offset (or equivalent).
  2. Persisted upload state (id, offset, chunks) — see persisting upload state in IndexedDB.
  3. A retry sender with backoff for the individual chunk PUTs.

Implementation

ResumableSession listens for connectivity changes, pauses on offline, and on online first reconciles with the server via HEAD, then drains the remaining chunks. navigator.onLine only confirms a link-layer connection, so the HEAD step is what proves the server actually received the bytes.

export interface SessionState {
  uploadId: string;
  url: string;        // base resource URL for this upload
  fileSize: number;
  chunkSize: number;
  offset: number;     // client's last-known confirmed byte
}

export interface ChunkSource {
  /** Return the blob for a given chunk index, or null if none. */
  chunkAt(index: number): Promise<Blob | null>;
  /** Persist the new confirmed offset (e.g. to IndexedDB). */
  saveOffset(offset: number): Promise<void>;
}

export class ResumableSession {
  private running = false;

  constructor(
    private state: SessionState,
    private source: ChunkSource,
    private putChunk: (url: string, offset: number, blob: Blob) => Promise<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);
    this.running = false;
  }

  private onOffline = (): void => {
    console.warn("[resume] offline — pausing, work is queued");
    this.running = false; // the drain loop will stop after its current await
  };

  private onOnline = (): void => {
    console.info("[resume] online — reconciling with server");
    void this.resume();
  };

  /** Ask the server for the authoritative offset, then drain. */
  private async resume(): Promise<void> {
    if (this.running) return;
    this.running = true;
    try {
      const serverOffset = await this.discoverServerOffset();
      if (serverOffset !== this.state.offset) {
        // Server truth wins — our optimistic offset may have skipped a lost chunk.
        console.info(`[resume] server at ${serverOffset}, client had ${this.state.offset}`);
        this.state.offset = serverOffset;
        await this.source.saveOffset(serverOffset);
      }
      await this.drain();
    } catch (err) {
      console.error("[resume] reconcile failed:", (err as Error).message);
      this.running = false;
    }
  }

  /** HEAD the resource to read how many bytes the server already holds. */
  private async discoverServerOffset(): Promise<number> {
    const res = await fetch(this.state.url, {
      method: "HEAD",
      headers: { "Tus-Resumable": "1.0.0" },
    });
    if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`);
    const raw = res.headers.get("Upload-Offset");
    const offset = raw ? Number(raw) : 0;
    if (Number.isNaN(offset)) throw new Error("Upload-Offset header malformed");
    return offset;
  }

  private async drain(): Promise<void> {
    while (this.running && this.state.offset < this.state.fileSize) {
      if (!navigator.onLine) {
        this.running = false; // bail; onOnline will pick up again
        return;
      }
      const index = Math.floor(this.state.offset / this.state.chunkSize);
      const blob = await this.source.chunkAt(index);
      if (!blob) break;

      await this.putChunk(this.state.url, this.state.offset, blob);

      const next = Math.min(this.state.offset + blob.size, this.state.fileSize);
      this.state.offset = next;
      await this.source.saveOffset(next); // persist only after confirmation
    }
    if (this.state.offset >= this.state.fileSize) {
      console.info("[resume] upload complete");
    }
    this.running = false;
  }
}

Line-by-line of the critical parts

  • attach registers the online/offline listeners and kicks off a resume if already online, so a page reload that lands online immediately reconciles.
  • onOffline flips running to false. The drain loop checks running and navigator.onLine between awaits, so it stops gracefully after the in-flight chunk rather than throwing.
  • navigator.onLine is necessary but not sufficient. It can report true on a captive portal or a connected-but-dead link. That is exactly why resume always does a HEAD reconcile before trusting any byte count.
  • discoverServerOffset issues a HEAD and reads Upload-Offset — the same header the tus protocol uses. This is the server’s authoritative count of received bytes.
  • Server truth overrides the client offset. If a chunk was sent but its response was lost during the disconnect, the client might have optimistically advanced; the HEAD corrects it downward so no bytes are skipped.
  • saveOffset is called only after putChunk resolves, so a crash never persists an offset past unconfirmed data — the durable-state contract from the IndexedDB store.
  • putChunk is injected, so you plug in the full-jitter retry sender from implementing exponential backoff for failed chunks without coupling this session to a transport.

The diagram traces the offline-to-online recovery path through the HEAD reconcile.

Network-loss resume state machine Uploading transitions to paused on offline; on online a HEAD reconcile reads the server offset before draining the queue, then completes. Uploading draining chunks Paused offline, queued Reconciling HEAD → offset offline online event → reconcile resume drain from server offset Complete
Offline pauses the drain; the online event triggers a HEAD reconcile before resuming.

Configuration gotchas

navigator.onLine reports true but uploads still fail. The flag only reflects a network interface, not reachability — a captive portal or dead gateway reads as online. Never resume on the event alone; always confirm with the HEAD reconcile and let chunk failures fall through to backoff.

Resume skips bytes and corrupts the file. Trusting the client’s optimistic offset after a disconnect re-sends from the wrong place when the last chunk’s response was lost. The HEAD-derived serverOffset must win, even when it is lower than the client’s count.

405 Method Not Allowed on the HEAD. The endpoint does not implement HEAD. Either add it server-side (the tus core requires it) or expose a small status endpoint returning the received-bytes count, and read that instead of Upload-Offset.

Duplicate resume loops after rapid online/offline flapping. Connectivity can toggle several times per second on a weak signal. The if (this.running) return guard in resume prevents overlapping drains; without it you get racing senders writing the same chunk.

Verification

Simulate a disconnect and confirm the client re-reads the server offset instead of trusting its own:

// In DevTools: go offline, then back online, and watch reconciliation.
// 1. Stub HEAD to report the server is behind the client's optimistic offset.
const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
  if (init?.method === "HEAD") {
    return new Response(null, { status: 200, headers: { "Upload-Offset": "8388608" } });
  }
  return realFetch(input, init);
};

// 2. Fire the events the browser would emit.
window.dispatchEvent(new Event("offline"));
window.dispatchEvent(new Event("online"));
// Expected log: "[resume] server at 8388608, client had <N>" then the drain resumes.
globalThis.fetch = realFetch;

FAQ

Why HEAD instead of trusting my local offset?

Because a disconnect can lose the response to a chunk that the server actually stored, or lose a chunk the client thought it sent. The server’s Upload-Offset is the only authoritative count; reconciling against it guarantees you neither skip nor duplicate bytes.

Is navigator.onLine reliable enough to drive resume?

It is a useful pause signal but a poor resume signal — it only knows about the local interface, not end-to-end reachability. Use the offline event to pause and the online event to attempt a reconcile, then let the HEAD and chunk failures decide reality.

How does this relate to the tus protocol?

tus standardizes exactly this HEADUpload-Offset → resume-PATCH handshake. If you adopt tus, much of this is built in; see building a resumable upload flow with tus. This pattern is for when you roll your own chunking.

Where do retries fit in?

Each putChunk should itself retry transient failures with backoff; this session handles the coarse online/offline boundary while implementing exponential backoff for failed chunks handles the per-chunk transient errors.