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
- A chunk endpoint that supports a
HEADrequest returning the currentUpload-Offset(or equivalent). - Persisted upload state (id, offset, chunks) — see persisting upload state in IndexedDB.
- 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
attachregisters theonline/offlinelisteners and kicks off a resume if already online, so a page reload that lands online immediately reconciles.onOfflineflipsrunningto false. Thedrainloop checksrunningandnavigator.onLinebetween awaits, so it stops gracefully after the in-flight chunk rather than throwing.navigator.onLineis necessary but not sufficient. It can reporttrueon a captive portal or a connected-but-dead link. That is exactly whyresumealways does aHEADreconcile before trusting any byte count.discoverServerOffsetissues aHEADand readsUpload-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
HEADcorrects it downward so no bytes are skipped. saveOffsetis called only afterputChunkresolves, so a crash never persists an offset past unconfirmed data — the durable-state contract from the IndexedDB store.putChunkis 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.
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 HEAD → Upload-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.