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
uploadIdmust 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
- A chunk endpoint that answers
HEADwith anUpload-Offsetresponse header (the tus core requirement) or an equivalent JSON status route. - Persisted session state β
uploadId, resource URL, file size, chunk size, confirmed offset β surviving reload. - 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.
- A chunk sender that retries transient failures on its own, so the session below only has to handle the coarse online/offline boundary.
Upload-Offsetadded toAccess-Control-Expose-Headers, orres.headers.get("Upload-Offset")returnsnullin 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.
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.
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
onOfflineaborts the in-flight request. Without theAbortController, the pendingfetchsits 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.onOnlinedebounces rather than resuming. A handover firesonlinetwo 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) returnis 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 partialPATCHat 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 with409 Conflict.AbortSignal.timeout(reconcileTimeoutMs)caps the reconcile itself. On a captive portal theHEADconnects, then hangs; eight seconds is long enough for a slow 3G round trip and short enough that the user sees a state change.404/410becomeUploadGone, 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-Offsetguard rejects nonsense. A proxy that rewrites the header, or an HTML error page returned with a200, yieldsNaN; sending the next chunk at offsetNaNproduces aContent-Rangeheader ofbytes NaN-NaN/*and a very confusing server log. saveOffsetruns only afterputChunkresolves. That is the invariant that keeps the correction unidirectional, and it is the same durability contract the IndexedDB store enforces.putChunkis 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:
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
navigator.onLine is true but every request fails
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.