Background & Offline Uploads

An upload started from a web page normally lives exactly as long as that page: navigate away, lock the phone long enough, lose signal in a tunnel or close the tab, and the transfer is gone along with every byte already sent. The browser has three mechanisms that let an upload outlive the page — a service worker running the transfer, Background Sync retrying queued work when connectivity returns, and Background Fetch handing the whole transfer to the browser itself — and each survives a different kind of interruption.

This topic belongs to upload fundamentals and browser APIs. It builds on the byte-level mechanics in file API and Blob objects and on the retry logic in browser timeout and retry logic, and it is the browser-side counterpart of the resumable protocols in resumable upload state machines.

Prerequisites

  • [ ] HTTPS everywhere — service workers, Background Sync and Background Fetch are all secure-context only.
  • [ ] A service worker registered with a scope covering every page that may be open during an upload.
  • [ ] An upload endpoint that is idempotent (an upload ID or Idempotency-Key) and, for large files, resumable by offset or part number.
  • [ ] IndexedDB access for storing queued files, offsets and upload IDs.
  • [ ] Feature detection for SyncManager and BackgroundFetchManager, with a page-level fallback for browsers that lack them.
  • [ ] A test device on a real mobile network — emulated offline mode does not reproduce captive portals, flapping signal or app switching.

How it works

Think of every upload as having an owner — the execution context holding the connection — and a record — durable state that says what remains to be sent. The owner decides which interruptions the upload survives; the record decides how much is lost when the owner dies anyway.

The page as owner is the default. It works everywhere and gives the richest progress events, but it dies on navigation and can be frozen when the tab is backgrounded on mobile.

The service worker as owner survives navigation between pages of the same app because one worker serves them all. It does not survive the browser being closed, and browsers terminate workers that run too long without a new event, so it needs a record — the committed offset — to pick up where it stopped. Proxying uploads through a service worker builds this.

Background Sync is not an owner but a trigger. It wakes the service worker when the browser believes connectivity has returned, so queued uploads in an IndexedDB outbox get sent without the user reopening the app. Queueing offline uploads with Background Sync implements the outbox.

The browser as owner, through Background Fetch, survives the tab and even the app being closed, and shows progress in system UI. It sends whole requests, so a network failure restarts that request. Uploading in the background with the Background Fetch API covers it.

Upload owners and the interruptions each survives A matrix of four owners against four interruptions. The page survives none of navigation, tab close or browser close, and survives a network drop only with a resumable protocol. The service worker survives navigation and network drops. Background Sync survives tab close and being offline for queued items. Background Fetch survives navigation and tab close but restarts on a network drop. Which interruption does each owner survive? owner navigate signal lost tab closed support page fetch / XHR no if resumable no all service worker loop yes yes pauses all Background Sync outbox yes waits yes Chromium Background Fetch yes restarts yes Chromium No single mechanism covers every row; production uploaders layer them with feature detection. The durable record — offset, outbox item, upload ID — is what makes every layer safe to retry.
Pick the owner for the interruption your users actually hit, and keep a durable record so any owner can pick up the work.

Step-by-step implementation

Step 1: Register the service worker and detect capabilities

Every other step depends on a controlling service worker and on knowing which background features the browser offers.

export interface BackgroundCaps { sw: boolean; sync: boolean; bgFetch: boolean; persisted: boolean }

export async function detect(): Promise<BackgroundCaps> {
  if (!("serviceWorker" in navigator)) return { sw: false, sync: false, bgFetch: false, persisted: false };
  const reg = await navigator.serviceWorker.register("/sw.js", { type: "module", scope: "/" });
  await navigator.serviceWorker.ready;
  const persisted = (await navigator.storage?.persist?.()) ?? false;
  return {
    sw: true,
    sync: "sync" in reg,
    bgFetch: "backgroundFetch" in reg,
    persisted,
  };
}

console.log(await detect());
// Chrome/Android: { sw: true, sync: true, bgFetch: true, persisted: true }
// Safari 17:      { sw: true, sync: false, bgFetch: false, persisted: false }

Log these capabilities with every upload in your analytics. Knowing what share of uploads ran with each owner is how you decide whether building the next layer is worth it.

Step 2: Give every upload an ID and a durable record

Before any bytes move, create the upload on the server and store a record locally. The ID makes retries idempotent; the record makes resumption possible from any context.

export interface UploadRecord { uploadId: string; name: string; size: number; offset: number; endpoint: string; createdAt: number }

export async function createUpload(file: File): Promise<UploadRecord> {
  const res = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
    body: JSON.stringify({ name: file.name, size: file.size, type: file.type }),
  });
  if (!res.ok) throw new Error(`could not create upload: HTTP ${res.status}`);
  const { uploadId, endpoint } = (await res.json()) as { uploadId: string; endpoint: string };
  return { uploadId, name: file.name, size: file.size, offset: 0, endpoint, createdAt: Date.now() };
}

Persist the record, together with the File itself if uploads must survive a full reload, in IndexedDB. The storage patterns are the same as in persisting upload state in IndexedDB.

Step 3: Choose the owner per upload

Route each upload to the strongest owner the browser supports and the situation calls for.

import type { BackgroundCaps } from "./detect.ts";

export type Owner = "background-fetch" | "service-worker" | "outbox" | "page";

export function chooseOwner(caps: BackgroundCaps, file: File, online: boolean): Owner {
  if (!online) return caps.sw ? "outbox" : "page";                // queue for later
  if (caps.bgFetch && file.size > 200 * 1024 * 1024) return "background-fetch";
  if (caps.sw) return "service-worker";
  return "page";
}

console.log(chooseOwner({ sw: true, sync: true, bgFetch: true, persisted: true },
  new File([new Uint8Array(10)], "a.jpg"), true));
// service-worker

Large files go to Background Fetch where it exists, because the chance of the user closing the tab grows with transfer time. Everything else runs in the service worker, which works in every browser. Offline submissions go to the outbox.

Step 4: Report progress from whichever owner holds the upload

Progress comes from different places depending on the owner: BroadcastChannel messages from the worker loop, progress events on a Background Fetch registration, or the outbox count. Normalise them into one stream for the UI.

export type ProgressEvent = { uploadId: string; fraction: number | null; state: "queued" | "sending" | "done" | "failed" };

export function subscribe(onEvent: (e: ProgressEvent) => void): () => void {
  const ch = new BroadcastChannel("upload-progress");
  ch.onmessage = ({ data }) => {
    if (data.done) onEvent({ uploadId: data.uploadId, fraction: 1, state: "done" });
    else if (data.error) onEvent({ uploadId: data.uploadId, fraction: null, state: "failed" });
    else onEvent({ uploadId: data.uploadId, fraction: data.offset / data.size, state: "sending" });
  };
  return () => ch.close();
}

subscribe((e) => console.log(e.uploadId, e.state, e.fraction?.toFixed(2)));
// 9c1f sending 0.12
// 9c1f sending 0.24

A queued upload has no fraction — show it as “waiting for connection”, never as 0%.

Step 5: Resume on every page load

Whatever owner was running, the next page load should ask: what uploads are unfinished, and who should continue them?

export async function resumeUnfinished(): Promise<void> {
  const reg = await navigator.serviceWorker.ready;
  // Background fetches keep their own state; re-attach progress listeners by ID.
  const bgf = (reg as ServiceWorkerRegistration & { backgroundFetch?: { getIds(): Promise<string[]> } }).backgroundFetch;
  const active = new Set((await bgf?.getIds()) ?? []);
  // Everything else: ask the worker to continue from committed offsets.
  reg.active?.postMessage({ type: "resume-all", skip: [...active] });
}

void resumeUnfinished();
Routing an upload to an owner If offline, the upload goes to the outbox and Background Sync sends it later. If online and the file is large and Background Fetch exists, the browser owns it. Otherwise the service worker loop owns it. Without a service worker the page owns it. chooseOwner() online? no: outbox Background Sync later yes: large + bgFetch? over 200 MB yes: Background Fetch no: SW loop
Two questions route every upload; the page-owned path is only for browsers without a service worker.

Configuration reference

Setting Type Default here Effect
SW scope path / Every page that may be open during an upload must be controlled.
Chunk size (SW loop) bytes 8 MiB Bytes re-sent after a worker stop; smaller means smoother progress, more requests.
Background Fetch threshold bytes 200 MB Files above it go to the browser-owned transfer where supported.
uploadTotal bytes file.size Drives system progress UI; must be exact.
downloadTotal bytes 0 Allowance for the response body; raise it if your endpoint returns JSON.
Sync tag string upload-outbox One tag drains the whole outbox.
Outbox max attempts integer 8 After this, the item is reported rather than retried forever.
navigator.storage.persist() call on first queue Reduces the chance the browser evicts queued files.
Presigned URL expiry seconds ≥ slowest upload Background owners cannot refresh credentials mid-request.

Edge cases and gotchas

Mobile browsers freeze background tabs

On Android and iOS, a backgrounded tab’s timers and network activity can be frozen within seconds. A page-owned upload simply stops, with no error, until the tab returns. Service-worker loops and Background Fetch are not subject to tab freezing in the same way — which is often the real reason mobile uploads “randomly stall”.

Service worker time limits

Browsers stop a worker that is idle (around 30 seconds) or has been handling a single event for too long (around five minutes in Chromium). Extending with waitUntil helps but does not remove the cap. Design the worker loop to be killed at any point: commit offsets after every chunk and resume on the next page load.

Credentials that expire while waiting

Queued and background uploads may start hours after they were created. Do not store presigned URLs in the outbox; store the upload ID and fetch a fresh URL immediately before sending. For Background Fetch, which cannot refresh mid-request, issue a URL whose expiry exceeds the worst-case transfer time.

Storage eviction

IndexedDB data is best-effort unless the origin is granted persistent storage. Under disk pressure the browser can delete the outbox — including files the user believes are “sent”. Request persistence, check navigator.storage.estimate() before queueing large files, and show queued items in the UI so a user notices if one disappears.

Duplicate sends

Every background mechanism can run the same work twice: a sync handler retried after its response was lost, two tabs resuming the same upload, a Background Fetch re-registered after a crash. Idempotency keys on the server and a “running” guard in the worker make duplicates harmless.

Completion rate of large mobile uploads by owner For uploads over 100 megabytes from mobile browsers, page-owned uploads completed 61 percent of the time, service-worker-owned uploads 78 percent, and Background Fetch uploads 93 percent, in a sample of uploads from a creator app. Large mobile uploads that completed (illustrative) page-owned 61% service worker loop 78% Background Fetch 93% Most page-owned failures were not errors at all — the user switched apps and the tab froze.
The failure you are fixing is usually the user leaving, not the network breaking.

Security considerations for background uploads

Moving uploads out of the page changes the security picture in ways that are easy to miss, because the code that sends the bytes is no longer the code the user is looking at.

Files at rest on the device. An outbox or a resumable worker loop stores the user’s files in IndexedDB, sometimes for hours. On a shared or managed computer, that is personal data sitting in the browser profile after the user thinks it has gone. Delete records as soon as the server confirms them, clear everything on logout, and do not queue files for accounts that are not signed in.

Authentication from the service worker. Requests from a service worker carry cookies for the origin like any other same-origin request, which is convenient and also means a session that expires mid-upload fails every chunk with 401. Handle 401 in the worker by pausing the job and asking any open page to re-authenticate, rather than retrying into a lockout. If you use bearer tokens, the worker needs a way to obtain fresh ones — typically by messaging a page, since the worker should not hold long-lived refresh tokens.

Scope of credentials. Background Fetch requests and outbox items should carry the narrowest credential possible: a presigned URL for one object, or an upload-session ID the server maps to exactly one destination. A general API token stored alongside queued files turns a stolen browser profile into account access.

Service worker updates. A new deployment installs a new worker version, which waits until old clients close unless you call skipWaiting(). Skipping the wait mid-upload replaces the worker running the loop; the new version must be able to read the old version’s IndexedDB records and resume them. Version your record schema and migrate in the activate event, or the first deploy after launch strands every in-flight upload.

Server-side limits still apply. A background owner is not a trusted client. Size limits, type checks, rate limits and virus scanning happen on the server exactly as for a foreground upload — the browser choosing to retry an item eight times does not change what the server should accept.

Designing the UX around background work

Background uploads change what the interface must communicate. When the page owns an upload, a progress bar in the form is enough; when the upload outlives the page, the user needs a place to see it from anywhere in the app. A small persistent tray — “2 uploads in progress” in the header, expandable to a list — is the common solution. It reads from the same normalised progress stream as the form did, so every page shows the same truth.

Be explicit about what the user may do. “You can leave this page — your upload will continue” is only true for service-worker and Background Fetch owners, and only within their limits; show it only when it is true. For outbox items, “Saved — will upload when you’re back online” sets the right expectation, and the tray should keep showing them until they are sent.

Finally, respect cancellation everywhere. A user who deletes a draft expects its upload to stop, whether it is in the outbox, in the worker loop or in a Background Fetch. Give each owner a cancel path — delete the outbox record, post a cancel message to the worker, call registration.abort() — and a server-side abort for the upload ID, so no background mechanism quietly finishes an upload nobody wants.

Verification

Test each owner against the interruption it claims to survive, on real devices:

1. Page-owned:       start 300 MB upload, switch apps for 60 s  → expect: stalls, resumes on return (if resumable)
2. SW loop:          start upload, navigate across 3 app pages   → expect: continuous PATCH sequence server-side
3. SW loop:          stop worker in DevTools mid-upload, reload  → expect: HEAD, then PATCH from committed offset
4. Outbox:           airplane mode, submit 3 photos, close tab, reconnect → expect: 3 PUTs, outbox empty
5. Background Fetch: start 1 GB upload, close the tab            → expect: system progress, SW success event, asset uploaded

And from the server side, confirm idempotency held throughout:

# No upload ID should have more than one completed object or more than one completion call.
grep 'POST /api/uploads/complete' access.log | awk '{print $7}' | sort | uniq -c | awk '$1>1'

Frequently Asked Questions

Do I need all three mechanisms?

No. Start with a service-worker loop over a resumable protocol: it works in every browser and fixes navigation, the most common interruption in web apps. Add the outbox if users submit while offline, and Background Fetch if large mobile uploads are central to the product and most of your users are on Chromium.

Can a PWA upload in the background like a native app?

Partly. An installed PWA gets the same service worker, Background Sync and Background Fetch as the browser, sometimes with more generous storage. It does not get unlimited background execution; native apps using OS background transfer services still survive more.

How do I test background behaviour in CI?

Headless Chromium supports service workers, IndexedDB and Background Sync triggered through the DevTools protocol, so Playwright or Puppeteer tests can queue an item offline, restore the network, fire the sync and assert the server received one request. Background Fetch’s system UI cannot be exercised headlessly; cover its service worker handlers with unit tests that construct synthetic events, and keep one manual device check in the release checklist.

What happens to a background upload if the user logs out?

Treat logout as cancellation: clear the outbox, stop the worker loop, abort any Background Fetch, and revoke the server-side upload session. Otherwise a queued upload can complete after logout using credentials that should no longer be valid.