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

  1. A service worker with scope over the pages that may be open during the upload.
  2. A chunked endpoint accepting PATCH with an offset header (the example uses a tus-like Upload-Offset), or presigned multipart part URLs.
  3. IndexedDB for { uploadId, offset, size, name } records, and the ability to store the File itself there if uploads must survive a full browser restart.
  4. BroadcastChannel (all current browsers) for progress fan-out.

Who lives longer than whom

Lifetimes of pages and the service worker during an upload Three page lifetimes follow each other as the user navigates: upload page, editor page, library page. The service worker spans all three and runs the chunk loop continuously, broadcasting progress to whichever page is open. The browser may still stop an idle worker, so the committed offset is persisted after each chunk. Pages come and go; the worker carries the upload pages /upload /videos/9c1f/edit /library worker chunk loop: 0% → 100%, one PATCH per chunk IndexedDB offset committed after every chunk If the browser stops the worker (idle or time limit), the next page load restarts the loop from the committed offset — the cost of an interruption is one chunk, never the whole file.
The worker outlives navigation but not everything; the persisted offset covers the gap when it does not.

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 File through postMessage. 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 File in IndexedDB with the job. That is what makes resume-all possible after the upload page is gone: the worker can re-read the file from its own storage. Without it, only the page holding the original File could resume.
  • HEAD before 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.
  • saveJob before postMessage. Progress shown to the user is progress that survives a restart. Reporting first and persisting second can show 60% and resume at 52%.
  • The running set. Two tabs both calling resume-all would otherwise start two loops for the same upload and interleave offsets.
  • e.waitUntil(...) on the message. An ExtendableMessageEvent can 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’s resume-all to continue.

Interruptions and what they cost

Bytes re-sent after three kinds of interruption Navigating between app pages costs nothing because the worker keeps running. A worker stopped by the browser costs at most one 8 megabyte chunk. Closing every tab of the app pauses the upload until the next visit, then resumes from the committed offset. Cost of each interruption, 8 MB chunks navigate in app 0 bytes worker never stops worker stopped ≤ 8 MB one chunk re-sent on resume all tabs closed paused resumes on next visit For uploads that must continue with every tab closed, hand each part to Background Fetch instead.
Within the app the worker is seamless; beyond it, durability comes from the persisted offset, not from the worker.

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

Where each upload owner fits A page-owned upload is simplest and works everywhere but dies on navigation. A service-worker-owned upload survives in-app navigation in every browser. Background Fetch survives closing the app but only in Chromium. A native app upload survives everything but needs an app. Survives more → fewer browsers or more work page fetch dies on navigation all browsers service worker survives in-app nav all browsers Background Fetch survives tab close Chromium native app OS background task install required The service worker is the widest-supported step up from a page-owned upload. Layer Background Fetch on top where available; keep the SW path as the fallback.
Each step right survives one more kind of interruption, at the cost of support or effort.

Verification

  1. 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.
  2. Click “Stop” on the worker in DevTools mid-upload, then navigate to any app page. The server log should show a HEAD followed by PATCH requests resuming from the last committed offset.
  3. 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.