Reacting to Offline and Online Events During Uploads
Treat offline as a reliable signal to pause — abort the in-flight chunk, keep the committed offset, show “waiting for connection” — and treat online only as a hint to check: send a tiny request to your own upload origin, and resume only when it succeeds, because navigator.onLine === true means “a network interface is up”, not “your server is reachable”.
The browser’s connectivity events are cheap, instant and half-true. offline fires when the device loses every network interface — airplane mode, Wi-Fi off with no cellular — and in that case it is right. online fires when an interface comes back, which says nothing about captive portals, DNS failures, a VPN that has not reconnected, or a corporate proxy that blocks your upload domain. Uploaders that resume the moment online fires waste their first retries against a network that is not really there, and uploaders that ignore both events keep hammering a dead connection. This page belongs to browser timeout and retry logic in upload fundamentals and browser APIs. For stalls where no event fires at all, see detecting stalled uploads with a progress watchdog.
When to use this approach
- Your uploads run long enough for connectivity to change during them — mobile users, large files, flaky Wi-Fi.
- You already have resumable or chunked uploads, so pausing and resuming costs at most one chunk.
- You want the UI to say “offline — will resume” instead of showing a stream of errors.
Prerequisites
navigator.onLineandwindowonline/offlineevents (every browser).- A cheap reachability endpoint on the upload origin —
HEAD /healthzreturning 204, with CORS allowing your page. - An upload loop that can be paused and resumed from a known offset, such as the one in resuming uploads after network loss.
What the events actually mean
Implementation
A connectivity monitor that combines the events with a real probe, and an upload loop that pauses on it:
type Net = "online" | "offline" | "checking";
export class Connectivity extends EventTarget {
state: Net = navigator.onLine ? "online" : "offline";
private probing: Promise<boolean> | null = null;
constructor(private readonly probeUrl: string) {
super();
window.addEventListener("offline", () => this.set("offline"));
window.addEventListener("online", () => void this.confirm());
}
private set(s: Net): void {
if (s === this.state) return;
this.state = s;
this.dispatchEvent(new CustomEvent("change", { detail: s }));
}
/** A real request to our own origin. Only this may move us back to "online". */
confirm(): Promise<boolean> {
if (this.probing) return this.probing;
this.set("checking");
this.probing = (async () => {
for (let attempt = 0; attempt < 6; attempt++) {
if (!navigator.onLine) { this.set("offline"); return false; }
try {
const res = await fetch(`${this.probeUrl}?t=${Date.now()}`, {
method: "HEAD", cache: "no-store", signal: AbortSignal.timeout(5000),
});
if (res.status === 204 || res.ok) { this.set("online"); return true; }
// A 200 with HTML from a captive portal would also be "ok" on some probes —
// hence 204 and HEAD: portals rarely mimic an empty 204.
} catch { /* not reachable yet */ }
await new Promise((r) => setTimeout(r, Math.min(15_000, 1000 * 2 ** attempt)));
}
this.set("offline");
return false;
})().finally(() => { this.probing = null; });
return this.probing;
}
/** Resolve when we are confirmed online. */
async ready(): Promise<void> {
while (this.state !== "online") {
if (this.state === "offline") await new Promise((r) => window.addEventListener("online", r, { once: true }));
await this.confirm();
}
}
}
export async function uploadChunks(file: File, endpoint: string, net: Connectivity, chunk = 4 * 1024 * 1024): Promise<void> {
let offset = await serverOffset(endpoint);
while (offset < file.size) {
await net.ready(); // pause here while offline or unconfirmed
const ctrl = new AbortController();
const onOffline = () => ctrl.abort(new DOMException("offline", "AbortError"));
window.addEventListener("offline", onOffline, { once: true });
const body = file.slice(offset, Math.min(offset + chunk, file.size));
try {
const res = await fetch(endpoint, {
method: "PATCH", body, signal: ctrl.signal,
headers: { "Upload-Offset": String(offset), "Content-Type": "application/offset+octet-stream" },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
offset += body.size;
} catch {
// Offline, reset, or error: re-sync from the server before the next attempt.
void net.confirm();
offset = await serverOffset(endpoint).catch(() => offset);
} finally {
window.removeEventListener("offline", onOffline);
}
}
}
async function serverOffset(endpoint: string): Promise<number> {
const res = await fetch(endpoint, { method: "HEAD", cache: "no-store" });
return Number(res.headers.get("Upload-Offset") ?? 0);
}
Line-by-line on the decisions that matter
offlineaborts the in-flight chunk immediately. When every interface is down the request cannot succeed, and without an abort it can hang until a TCP timeout minutes later. Aborting frees the UI to show the offline state at once.onlineonly triggersconfirm(). The state moves to"checking", not"online", and the upload loop keeps waiting inready()until a probe to your own origin returns.HEADexpecting204. A captive portal intercepts HTTP and returns its login page with200 OK. A probe that accepts any 2xx would think the portal is your server. An empty 204 from a dedicated endpoint is hard for a portal to fake.cache: "no-store"and the timestamp query stop a cached response from answering.- Probe with backoff. After
online, interfaces often come up seconds before DNS and routing work. Probing at 1, 2, 4, 8 seconds catches the moment it becomes usable without flooding the network. - Re-read the server offset after any failure. A chunk aborted by
offlinemay or may not have been committed server-side. Asking the server removes the guess.
The pause-resume state machine
Configuration gotchas
The probe succeeds but the upload host is still unreachable. You probed a different host — the app’s origin — while uploads go to a storage domain. Probe the host the upload actually uses, or at least one on the same network path (the CDN edge in front of it).
online never fires on some desktops. Machines with virtual adapters (VPNs, Docker, VM bridges) often never report all interfaces down, so offline does not fire when Wi-Fi drops. Do not rely on events alone; every failed chunk should also trigger confirm(), as the loop does.
Probe requests fail with CORS errors. A HEAD to another origin needs Access-Control-Allow-Origin; without it the probe always fails and the uploader believes it is offline forever. Allow CORS on the health endpoint, or probe with mode: "no-cors" and treat any opaque response as reachable (less precise but CORS-free).
Tabs in the background resume late. Browsers throttle timers in hidden tabs, so probe backoff stretches. Re-run confirm() on visibilitychange to visible.
What the user should see
Designing the health endpoint
The probe endpoint deserves a few minutes of design, because the uploader’s behaviour depends on it entirely. Serve it from the same infrastructure as uploads — ideally the same hostname — so a successful probe really means “uploads can reach us”. Make it cheap: no database access, no authentication, a static 204 with Cache-Control: no-store. Allow CORS for your origins, and include it in rate-limit exemptions so a thousand clients reconnecting after an outage do not lock themselves out.
Do not overload it with meaning. A health endpoint that returns 503 when a background queue is slow will pause every uploader in the world while uploads themselves would have worked. The question the probe answers is narrow: can this client open a connection to the upload service and get a response? Deeper health belongs in your monitoring, not in the client’s decision to send the next chunk.
Verification
- Start a chunked upload, toggle airplane mode on a phone: the UI shows “offline” within a second and no requests are attempted.
- Toggle it off on a network with a captive portal: the UI shows “reconnecting” and stays paused until you log in to the portal, then resumes.
- In DevTools, set Network to “Offline” and back: the loop resumes from the server’s offset, confirmed by one
HEADbefore the nextPATCH.
// Quick console check of the probe's judgement.
const net = new Connectivity("https://uploads.example.com/healthz");
net.addEventListener("change", (e) => console.log("net:", (e as CustomEvent).detail));
console.log("confirmed:", await net.confirm());
Frequently Asked Questions
Is the Network Information API useful here?
navigator.connection (Chromium only) reports an estimated connection type and speed, which can inform chunk size or a “wait for Wi-Fi” option. It does not tell you whether your server is reachable, so it complements rather than replaces the probe.
Should I pause uploads on visibilitychange to hidden?
No — keep uploading in the background where the browser allows it. Do re-check connectivity when the page becomes visible again, because mobile browsers may have frozen the tab and the network may have changed while it was hidden.
How many clients will probe at once after an outage?
All of them, within seconds of connectivity returning — which is why the probe backs off with growing delays and why the endpoint must be cheap. If your own service was the outage, add jitter to the first probe delay as well, so a large client population does not reconnect in one synchronised wave.
What about uploads handled by a service worker?
The same events fire in pages, not in service workers. A worker-owned upload should rely on failed fetches and probes, and Background Sync can wake it when the browser believes connectivity is back — see queueing offline uploads with Background Sync.