Uploading in the Background with the Background Fetch API

From a page controlled by a service worker, call registration.backgroundFetch.fetch(id, [new Request(url, { method: "PUT", body: file })], { title, downloadTotal: 0, uploadTotal: file.size }); the browser then owns the transfer, shows its own progress UI, keeps going after the tab closes, and wakes your service worker with backgroundfetchsuccess or backgroundfetchfail when it ends — and where backgroundFetch is missing, fall back to an ordinary fetch() that runs only while the page is open.

A 2 GB video upload from a phone takes minutes even on good connections, and mobile users switch apps, lock screens and close tabs. An upload tied to a page’s lifetime dies with the page. Background Fetch moves the request out of the page into the browser’s download manager, which survives all of that and reports progress in the operating system’s notification shade. This page belongs to background and offline uploads in upload fundamentals and browser APIs. The resumable alternative that works everywhere, at the cost of the page staying open, is in resumable upload state machines.

When to use this approach

  • Uploads are large (hundreds of megabytes and up) and come mostly from Chromium-based browsers on Android and desktop, where Background Fetch ships.
  • Users realistically leave the page mid-upload — a creator app, a backup tool, a field-reporting app.
  • A single request per file is acceptable: Background Fetch sends whole requests, so it suits single-PUT uploads to a presigned URL rather than a chunked protocol.

Prerequisites

  1. A service worker registered on the page’s scope, served over HTTPS.
  2. An upload endpoint that accepts one PUT of the whole file — a presigned S3 URL works well, as in generating secure presigned URLs with AWS SDK v3, with an expiry longer than the slowest upload you expect.
  3. TypeScript with lib: ["DOM", "WebWorker"]; Background Fetch types are not in the default DOM lib, so the declarations below are included.
  4. A fallback path for Safari and Firefox, which do not implement Background Fetch.

Who owns the request

Ownership of an upload with and without Background Fetch With fetch, the page owns the request and closing the tab aborts it. With Background Fetch, the page hands the request to the browser's fetch manager, which shows system progress and continues after the tab closes, then wakes the service worker with a success or failure event. Who is holding the upload when the tab closes? fetch() from the page page server tab closed → request aborted screen locked → may be suspended progress lost at 71% backgroundFetch.fetch() browser fetch manager server tab closed → keeps uploading progress in the notification shade SW woken on success / fail
Background Fetch changes who owns the connection, which is the only reason it survives the page.

Implementation

The page side starts the upload and listens for progress while it is open:

// Minimal types — Background Fetch is not in TypeScript's DOM lib yet.
interface BackgroundFetchRegistration extends EventTarget {
  id: string; uploaded: number; uploadTotal: number;
  result: "" | "success" | "failure";
  failureReason: "" | "aborted" | "bad-status" | "fetch-error" | "quota-exceeded" | "download-total-exceeded";
  abort(): Promise<boolean>;
}
interface BackgroundFetchManager {
  fetch(id: string, requests: RequestInfo[], options?: {
    title?: string; icons?: { src: string; sizes?: string; type?: string }[];
    downloadTotal?: number; uploadTotal?: number;
  }): Promise<BackgroundFetchRegistration>;
  get(id: string): Promise<BackgroundFetchRegistration | undefined>;
}
type SWRegWithBGF = ServiceWorkerRegistration & { backgroundFetch?: BackgroundFetchManager };

export async function startUpload(
  file: File,
  presignedPutUrl: string,
  uploadId: string,
  onProgress: (fraction: number) => void,
): Promise<"background" | "foreground"> {
  const reg = (await navigator.serviceWorker.ready) as SWRegWithBGF;

  if (reg.backgroundFetch) {
    const request = new Request(presignedPutUrl, {
      method: "PUT",
      body: file,
      headers: { "Content-Type": file.type || "application/octet-stream" },
    });
    const bgf = await reg.backgroundFetch.fetch(uploadId, [request], {
      title: `Uploading ${file.name}`,
      icons: [{ src: "/icons/upload-192.png", sizes: "192x192", type: "image/png" }],
      downloadTotal: 0,              // we expect only a tiny response body
      uploadTotal: file.size,        // drives the system progress bar
    });
    bgf.addEventListener("progress", () => onProgress(bgf.uploaded / Math.max(1, bgf.uploadTotal)));
    return "background";
  }

  // Fallback: an ordinary request that lives only as long as this page.
  const res = await fetch(presignedPutUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type || "application/octet-stream" },
  });
  if (!res.ok) throw new Error(`upload failed: HTTP ${res.status}`);
  onProgress(1);
  return "foreground";
}

/** On page load: re-attach to an upload that was running when the tab closed. */
export async function resume(uploadId: string, onProgress: (f: number) => void): Promise<boolean> {
  const reg = (await navigator.serviceWorker.ready) as SWRegWithBGF;
  const bgf = await reg.backgroundFetch?.get(uploadId);
  if (!bgf) return false;
  bgf.addEventListener("progress", () => onProgress(bgf.uploaded / Math.max(1, bgf.uploadTotal)));
  onProgress(bgf.uploaded / Math.max(1, bgf.uploadTotal));
  return true;
}

And the service worker handles the outcome even when no page is open:

/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

interface BGFEvent extends ExtendableEvent {
  registration: { id: string; result: string; failureReason: string;
    matchAll(): Promise<{ responseReady: Promise<Response> }[]> };
  updateUI(opts: { title: string }): Promise<void>;
}

self.addEventListener("backgroundfetchsuccess", (e: Event) => {
  const event = e as BGFEvent;
  event.waitUntil((async () => {
    const [record] = await event.registration.matchAll();
    const res = await record.responseReady;
    const ok = res.ok;                            // bad-status is reported as success at this layer
    await fetch("/api/uploads/complete", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uploadId: event.registration.id, ok, status: res.status, etag: res.headers.get("ETag") }),
    });
    await event.updateUI({ title: ok ? "Upload complete" : `Upload failed (HTTP ${res.status})` });
  })());
});

self.addEventListener("backgroundfetchfail", (e: Event) => {
  const event = e as BGFEvent;
  event.waitUntil((async () => {
    await fetch("/api/uploads/failed", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uploadId: event.registration.id, reason: event.registration.failureReason }),
    });
    await event.updateUI({ title: "Upload failed — open the app to retry" });
  })());
});

self.addEventListener("backgroundfetchclick", (e: Event) => {
  const event = e as BGFEvent;
  event.waitUntil(self.clients.openWindow(`/uploads/${event.registration.id}`));
});

Line-by-line on the parameters that matter

  • uploadTotal: file.size. The browser uses it for the progress bar and as an upper bound; if the body turns out larger, the fetch fails. Always pass the exact size.
  • downloadTotal: 0. Background Fetch was designed for downloads; the response body counts against this limit. A presigned PUT returns an empty body, so zero is correct — but if your endpoint returns JSON, set a small allowance or the fetch fails with download-total-exceeded.
  • The id. It must be unique among active fetches for this service worker; reuse your upload ID so resume() can find it after a reload. Calling fetch again with an active ID rejects with a TypeError.
  • responseReady and res.ok. A 403 from an expired presigned URL is a successful fetch at this layer; result is "success" and the status is on the response. Check ok yourself.
  • Completion reported from the service worker. The page may be long gone. The service worker tells your API directly, so the asset moves to “uploaded” even if the user never reopens the app — the same confirmation step described in confirming uploads before committing database records.

Lifecycle of a background upload

States of a background fetch from start to outcome A background fetch starts pending, moves to uploading while the browser sends the body, and ends in success, failure or aborted. Success and failure wake the service worker; the user tapping the notification fires backgroundfetchclick. States and the events that end them pending uploading progress events success check response.ok failure network, quota aborted user cancelled service worker woken A failed background fetch does not retry itself; your service worker decides whether to start a new one.
Success means "a response arrived", not "the upload was accepted" — the status code still has to be checked.

Configuration gotchas

TypeError: Failed to execute 'fetch' on 'BackgroundFetchManager': … quota. The browser estimates storage for the whole fetch against the origin’s quota, and some versions count the upload body too. Check navigator.storage.estimate() before starting very large uploads and fall back to foreground upload if headroom is short.

failureReason: "download-total-exceeded". The server returned a body larger than downloadTotal. S3 presigned PUTs return an empty 200; a custom endpoint returning JSON needs a non-zero allowance.

Presigned URL expired mid-upload. Background Fetch cannot refresh credentials. Give the URL an expiry comfortably beyond the slowest expected upload (hours, not minutes), and remember that temporary credentials cap it — why presigned URLs expire early with temporary credentials explains the ceiling.

No progress events on the page after a reload. You created a new registration instead of calling backgroundFetch.get(id). Store active upload IDs (IndexedDB) and reattach on load.

Choosing between background and resumable

Background Fetch sends one request; if the network drops at 90%, the fetch fails and a retry starts from zero. A chunked, resumable protocol survives drops but not tab closure. They solve different failures, and for very large files on flaky networks neither alone is ideal.

Which failures each approach survives Background Fetch survives tab close and app switch but restarts from zero on a network drop. Resumable chunked upload survives network drops but stops on tab close. A service worker driving chunked upload survives both while the worker is alive. Failure survived? approach tab closed network drop browsers Background Fetch, single PUT yes restart Chromium resumable chunks from the page no yes all chunks driven by a service worker briefly yes all Service workers are killed after ~30 s idle or ~5 min busy; only Background Fetch truly outlives the page.
Pick by the failure your users actually hit: closing the app favours Background Fetch; dropping signal favours chunks.

A pragmatic combination for mobile creators: split very large files into a few large parts (say 100 MB each, as an S3 multipart upload), and send each part as its own Background Fetch with its own presigned part URL. A network failure then costs one part, not the whole file, and the upload still survives the app being closed. The part mechanics are in presigning S3 multipart upload parts.

Verification

  1. In Chrome on Android (or desktop Chrome), start an upload of a 500 MB file, then close the tab. The notification (desktop: download bubble) should keep showing progress.
  2. DevTools → Application → Background Services → Background Fetch → “Start recording”. Reproduce the upload; the panel lists backgroundfetchsuccess or …fail with the registration ID.
  3. Confirm your API received the completion call from the service worker, and that the object exists:
aws s3api head-object --bucket uploads --key "uploads/$UPLOAD_ID/original" \
  --query '[ContentLength,ETag]'

Frequently Asked Questions

Does Background Fetch work in Safari or Firefox?

Not at the time of writing; it ships in Chromium-based browsers. Feature-detect registration.backgroundFetch and fall back to an in-page upload, ideally resumable, so every user gets the best behaviour their browser allows.

Can I upload several files in one background fetch?

Yes — pass several Request objects and they share one progress UI. But the fetch succeeds or fails as a unit, so one bad file fails the batch. For independent files, one background fetch per file is easier to retry.

Is there a size limit?

No fixed one, but the browser checks storage quota before starting, and very long uploads are more likely to hit a network interruption that fails the whole request. For multi-gigabyte files, the multipart-plus-Background-Fetch combination above is more robust.