Proxying Uploads Through a Service Worker
Move the chunk loop out of the page and into the service worker: the page posts { type: "start", file, uploadId } over postMessage, the worker slices the File and sends chunks with fetch, persists the committed offset in IndexedDB after each chunk, broadcasts progress on a BroadcastChannel to every open tab, and keeps itself alive between chunks with event.waitUntil on the message event — so navigating from the upload page to another page of your app does not interrupt the transfer.
Multi-page apps and apps that do full navigations lose in-page uploads the moment the user clicks a link. Even single-page apps reload for auth redirects and new deployments. A service worker is shared by every page in its scope and outlives any one of them, which makes it the natural owner of a long transfer — within limits the browser enforces. This page is part of background and offline uploads in upload fundamentals and browser APIs. It combines the chunking from slicing large files with Blob.slice with the durable offsets from resumable upload state machines.
When to use this approach
- Users navigate between pages of your app while uploads run — upload a video, then go and edit its description on another page.
- You need uploads to work in every major browser, including those without Background Fetch.
- Your upload protocol is chunked and resumable (tus, S3 multipart with presigned parts, or your own offset-based endpoint), so a worker restart costs at most one chunk.
Prerequisites
- A service worker with scope over the pages that may be open during the upload.
- A chunked endpoint accepting
PATCHwith an offset header (the example uses a tus-likeUpload-Offset), or presigned multipart part URLs. - IndexedDB for
{ uploadId, offset, size, name }records, and the ability to store theFileitself there if uploads must survive a full browser restart. BroadcastChannel(all current browsers) for progress fan-out.
Who lives longer than whom
Implementation
The service worker owns the loop:
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CHUNK = 8 * 1024 * 1024;
const progress = new BroadcastChannel("upload-progress");
interface Job { uploadId: string; file: File; endpoint: string; offset: number }
// --- tiny IndexedDB helpers for offsets and files ---
function db(): Promise<IDBDatabase> {
return new Promise((res, rej) => {
const r = indexedDB.open("sw-uploads", 1);
r.onupgradeneeded = () => r.result.createObjectStore("jobs", { keyPath: "uploadId" });
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
async function saveJob(job: Job): Promise<void> {
const d = await db();
await new Promise<void>((res, rej) => {
const t = d.transaction("jobs", "readwrite");
t.objectStore("jobs").put(job);
t.oncomplete = () => res(); t.onerror = () => rej(t.error);
});
d.close();
}
async function loadJobs(): Promise<Job[]> {
const d = await db();
const jobs = await new Promise<Job[]>((res, rej) => {
const r = d.transaction("jobs").objectStore("jobs").getAll();
r.onsuccess = () => res(r.result as Job[]); r.onerror = () => rej(r.error);
});
d.close();
return jobs;
}
async function dropJob(uploadId: string): Promise<void> {
const d = await db();
d.transaction("jobs", "readwrite").objectStore("jobs").delete(uploadId);
d.close();
}
const running = new Set<string>();
async function run(job: Job): Promise<void> {
if (running.has(job.uploadId)) return; // two tabs asked for the same upload
running.add(job.uploadId);
try {
// Ask the server where it is; our stored offset may be behind if a response was lost.
const head = await fetch(job.endpoint, { method: "HEAD", headers: { "Tus-Resumable": "1.0.0" } });
job.offset = Number(head.headers.get("Upload-Offset") ?? job.offset);
while (job.offset < job.file.size) {
const body = job.file.slice(job.offset, Math.min(job.offset + CHUNK, job.file.size));
const res = await fetch(job.endpoint, {
method: "PATCH",
body,
headers: {
"Tus-Resumable": "1.0.0",
"Upload-Offset": String(job.offset),
"Content-Type": "application/offset+octet-stream",
},
});
if (res.status === 409) { // offset mismatch: resync and continue
job.offset = Number(res.headers.get("Upload-Offset") ?? job.offset);
continue;
}
if (!res.ok) throw new Error(`chunk failed: HTTP ${res.status}`);
job.offset = Number(res.headers.get("Upload-Offset") ?? job.offset + body.size);
await saveJob(job); // durable before we report progress
progress.postMessage({ uploadId: job.uploadId, offset: job.offset, size: job.file.size });
}
await dropJob(job.uploadId);
progress.postMessage({ uploadId: job.uploadId, done: true });
} catch (err) {
progress.postMessage({ uploadId: job.uploadId, error: String(err) });
} finally {
running.delete(job.uploadId);
}
}
self.addEventListener("message", (e: ExtendableMessageEvent) => {
const msg = e.data as { type: string; uploadId?: string; file?: File; endpoint?: string };
if (msg.type === "start" && msg.uploadId && msg.file && msg.endpoint) {
const job: Job = { uploadId: msg.uploadId, file: msg.file, endpoint: msg.endpoint, offset: 0 };
// waitUntil keeps the worker alive while the loop runs (within the browser's limits).
e.waitUntil(saveJob(job).then(() => run(job)));
}
if (msg.type === "resume-all") {
e.waitUntil(loadJobs().then((jobs) => Promise.all(jobs.map(run))).then(() => undefined));
}
});
The page starts uploads, resumes unfinished ones on load, and listens for progress:
export async function startInWorker(file: File, uploadId: string, endpoint: string): Promise<void> {
const reg = await navigator.serviceWorker.ready;
reg.active?.postMessage({ type: "start", uploadId, file, endpoint });
}
export async function resumeAllOnLoad(): Promise<void> {
const reg = await navigator.serviceWorker.ready;
reg.active?.postMessage({ type: "resume-all" });
}
export function onProgress(cb: (m: { uploadId: string; offset?: number; size?: number; done?: boolean; error?: string }) => void): () => void {
const ch = new BroadcastChannel("upload-progress");
ch.onmessage = (e) => cb(e.data);
return () => ch.close();
}
// Every page of the app calls this once:
void resumeAllOnLoad();
onProgress((m) => {
if (m.done) console.log(`${m.uploadId} complete`);
else if (m.size) console.log(`${m.uploadId} ${Math.round((100 * (m.offset ?? 0)) / m.size)}%`);
});
Line-by-line on the decisions that matter
- Passing the
FilethroughpostMessage. Files are structured-cloneable; the worker receives a reference to the same on-disk data, not a copy in memory. Slicing it inside the worker reads only each chunk. - Storing the
Filein IndexedDB with the job. That is what makesresume-allpossible after the upload page is gone: the worker can re-read the file from its own storage. Without it, only the page holding the originalFilecould resume. HEADbefore resuming. The server’s offset is authoritative. A response lost in transit means the server committed a chunk the worker never recorded; asking first avoids re-sending it and the 409 that would follow.saveJobbeforepostMessage. Progress shown to the user is progress that survives a restart. Reporting first and persisting second can show 60% and resume at 52%.- The
runningset. Two tabs both callingresume-allwould otherwise start two loops for the same upload and interleave offsets. e.waitUntil(...)on the message. AnExtendableMessageEventcan extend the worker’s lifetime. Browsers still cap it — Chromium terminates a worker whose event has run for about five minutes without a new event — so long uploads rely on the next page load’sresume-allto continue.
Interruptions and what they cost
Configuration gotchas
DataCloneError: Failed to execute 'postMessage' on 'ServiceWorker'. You passed something non-cloneable alongside the file — a class instance with methods, a DOM node, a Response. Send plain data plus the File.
Uploads stop after about five minutes with no error. The browser terminated the worker at its event time limit. This is expected; make sure every page calls resume-all on load, and consider fewer, larger chunks so each event does more work before the cap.
Progress stops updating in some tabs. A BroadcastChannel only reaches contexts of the same origin that created a channel with the same name. Tabs opened before the service worker updated may run old page code without a listener — reload them, or have the new worker’s activate event call clients.claim().
TypeError: Failed to fetch for every chunk from the worker. The worker’s fetches go through CORS like the page’s; the upload endpoint must allow your origin and expose Upload-Offset via Access-Control-Expose-Headers, or the worker cannot read it.
Service worker versus the other options
Verification
- Start a 1 GB upload on
/upload, then click through to two other pages of the app. DevTools → Application → Service Workers shows the worker still running; the server log shows chunks continuing without a gap. - Click “Stop” on the worker in DevTools mid-upload, then navigate to any app page. The server log should show a
HEADfollowed byPATCHrequests resuming from the last committed offset. - Open two tabs; both should show the same progress from the
BroadcastChannel, and the server should never receive two chunks for the same offset.
# Server-side: offsets must be strictly increasing, with at most one repeated chunk after a stop.
grep 'PATCH /files/9c1f' access.log | awk '{print $NF}' | uniq -c | awk '$1>1'
Frequently Asked Questions
Does this keep uploading after the browser is closed?
No. A service worker runs only while the browser does, and only while it has events to handle. Closing the browser pauses the upload; the persisted offset means the next visit resumes it. For “keep going after I close everything”, use Background Fetch where supported or a native app.
Can the page still show a precise progress bar?
Yes — the worker broadcasts the committed offset after every chunk. Between chunks there is no byte-level progress (fetch upload progress is not observable from a worker), so smaller chunks give a smoother bar at the cost of more requests.
Should the service worker intercept form posts instead?
Intercepting a fetch event for a form submission and responding immediately while uploading in the background is possible, but it hides failures from the page and complicates retries. An explicit postMessage API between page and worker is easier to reason about and to test.