Background Fetch¶
The Background Fetch API hands a set of downloads (or uploads) to the browser itself, so they continue when the user closes the tab, the service worker is terminated, or the connection drops and returns. You call registration.backgroundFetch.fetch(id, requests, options) from a page, the browser shows a progress UI with your title and icon, and when every request has finished it wakes your service worker with backgroundfetchsuccess (or backgroundfetchfail / backgroundfetchabort) so you can move the responses into Cache Storage. It is built for podcasts, videos, course modules, maps and other files too large to download reliably with fetch(), and it exists only in Chromium, where its future has been openly debated.
Key takeaways
backgroundFetch.fetch(id, requests, { title, icons, downloadTotal })resolves with aBackgroundFetchRegistrationas soon as the job is stored; the downloads run in the browser's download machinery, not in your worker.- The registration exposes
downloaded,downloadTotal,uploaded,uploadTotal,result,failureReason, aprogressevent,abort(), andmatch()/matchAll()returningBackgroundFetchRecords whoseresponseReadypromise yields eachResponse. - Exactly one of
backgroundfetchsuccess,backgroundfetchfailorbackgroundfetchabortfires in the service worker when the job ends; copy the records into Cache Storage during that event, because the browser discards them when it settles.updateUI()may be called once in the success and fail events. downloadTotalis a hard cap, not a hint: exceed it and the whole job fails with"download-total-exceeded".- Chromium limits each origin to 5 active fetches, ties the permission to the Automatic downloads setting, and since 2026 rejects
fetch()calls made from inside a service worker by default. - Chromium's usage is tiny (less than 0.00002% of page loads in November 2025, according to the blink-dev intent to deprecate) and that attempt to deprecate the API did not reach consensus; Chromium is now tightening its security (for CORS and Local Network Access enforcement, Chrome Platform Status lists enforcement for Chrome 154). Always ship a foreground fallback.
Chromium-only, with an uncertain future
Background Fetch is available in Chrome and Edge (desktop and Android) from versions 74 and 79, Opera 62 and Samsung Internet 11. Firefox, Safari and Android WebView do not implement it, and neither WebKit nor Mozilla has published a supporting position. In November 2025 Chromium engineers posted an intent to deprecate it because of extremely low usage and ongoing security maintenance; according to the Chrome Platform Status entry for the 2026 CORS change, that effort "did not reach consensus for removal". Build it as an enhancement over a download path that works without it.
When Background Fetch is the right tool¶
A service worker is a poor place for a 500 MB download. Engines terminate workers that run too long (Chromium after 5 minutes per event, Firefox after its idle and grace timeouts; see Advanced Techniques), and a page-driven download dies when the tab closes or the phone locks. Background Fetch moves the transfer out of JavaScript entirely:
| Approach | Survives tab close | Survives worker termination | Survives connection loss | Visible to the user | Engines |
|---|---|---|---|---|---|
fetch() in the page | ❌ | n/a | ❌ (restart manually) | Only your UI | All |
fetch() in the service worker inside waitUntil() | Partly, until the event times out | ❌ | ❌ | No | All |
| Background Sync event | ✅ (retries) | ❌ within one event (3 min cap) | Retries whole job | No | Chromium |
| Background Fetch | ✅ | ✅ (runs in the browser) | ✅ pauses and resumes | ✅ browser UI | Chromium |
Typical uses:
- Media for offline playback: podcast episodes, video lessons, audiobooks. The request list can include the media file, artwork, subtitles and a JSON manifest; they succeed or fail as one job.
- Large offline bundles: a travel guide with map tiles, a course module, a game level pack.
- Large uploads: a video or a batch of photos, where the request body is handed to the browser up front (with caveats, see Uploads).
It is the wrong tool for small, frequent requests (plain fetch()), for delivering writes made offline (Background Sync), for periodic refreshes (Periodic Background Sync), and for anything the user did not explicitly start: the browser shows every background fetch in its UI, so a surprise download of 200 MB of assets is visible and easy to cancel.
How a background fetch runs¶
sequenceDiagram
participant Page
participant Browser as Browser download service
participant UI as Browser UI
participant SW as Service worker
participant Server
Page->>Browser: backgroundFetch.fetch(id, requests, options)
Browser-->>Page: BackgroundFetchRegistration
Browser->>UI: show title, icon, progress
Browser->>Server: GET episode.mp3
Server-->>Browser: 200, streaming body
Browser-->>Page: progress events while a page is open
Note over Page: user closes the tab
Note over Browser: connection drops, fetch pauses
Browser->>Server: GET episode.mp3 with Range
Server-->>Browser: 206 Partial Content
Browser->>SW: backgroundfetchsuccess
SW->>Browser: matchAll(), responseReady
SW->>SW: cache.put() each response
SW->>UI: updateUI title
Note over Browser: event settles, records discarded The key property: between fetch() resolving and the final event, no JavaScript of yours needs to run. The worker is started only for the terminal event and for clicks on the UI.
BackgroundFetchManager: fetch(), get(), getIds()¶
The Background Fetch specification is a WICG Draft Community Group Report (last published 21 April 2021), edited by Jake Archibald and Peter Beverloo.
partial interface ServiceWorkerRegistration {
readonly attribute BackgroundFetchManager backgroundFetch;
};
[Exposed=(Window,Worker)]
interface BackgroundFetchManager {
Promise<BackgroundFetchRegistration> fetch(DOMString id,
(RequestInfo or sequence<RequestInfo>) requests,
optional BackgroundFetchOptions options = {});
Promise<BackgroundFetchRegistration?> get(DOMString id);
Promise<sequence<DOMString>> getIds();
};
dictionary BackgroundFetchUIOptions {
sequence<ImageResource> icons;
DOMString title;
};
dictionary BackgroundFetchOptions : BackgroundFetchUIOptions {
unsigned long long downloadTotal = 0;
};
fetch(id, requests, options)¶
id is your identifier for the job. It must be unique among the registration's active fetches: reusing the id of a fetch that is still running rejects with a TypeError ("There already is a registration for the given id."), while reusing the id of a finished one is fine. Encode what the job is in the id ("episode:1234"), because the id is all the service worker gets to recognize the job by.
requests is a URL string, a Request, or an array of them (an empty array rejects with a TypeError, "At least one request must be given."). Each is passed through the Request constructor, so relative URLs resolve against the calling document and you can set method, headers and body.
options:
| Option | Type | Default | Meaning |
|---|---|---|---|
title | string | "" | Shown in the browser's progress UI. The spec says the UI "may" show it; Chromium does. |
icons | array of ImageResource (src, sizes, type, label) | [] | Same shape as manifest icons. The browser picks one for its UI and loads it; a failed icon load does not fail the fetch. |
downloadTotal | number (bytes) | 0 (unknown) | Total size of all response bodies. Used for the progress bar; if the stored bytes ever exceed it, every request is aborted and the job fails with "download-total-exceeded". |
downloadTotal counts the response body bytes the browser stores, which are the decoded bytes: for a response served with Content-Encoding: gzip, that is the uncompressed size, not the Content-Length. For media files, which are rarely content-encoded, the two match. Supply the exact figure from your own metadata, or omit it; guessing low guarantees failure and guessing high makes the progress bar stall below 100%.
The promise resolves once the browser has stored the job (including request bodies), before any download starts. Where it rejects, Chromium's messages differ from the spec in places:
| Condition | Spec | Chromium |
|---|---|---|
| Empty request list | TypeError | TypeError: "At least one request must be given." |
A request with mode: "no-cors" | TypeError | TypeError: "Refused to fetch '…' because the request mode must not be no-cors." |
URL scheme other than http:/https:, bad port, credentials in the URL, dangling markup, blocked by the page's CSP connect-src | Through Fetch | TypeError: "Refused to fetch '…' because …" (checked before anything is stored) |
| A request body already read | TypeError (Request constructor) | TypeError: "Request body is already used" |
| No active service worker | TypeError | TypeError: "No active registration available on the ServiceWorkerRegistration." |
| An active fetch with the same id | TypeError | TypeError: "There already is a registration for the given id." |
Permission "denied" | NotAllowedError | TypeError: "This origin does not have permission to start a fetch." |
| Storage quota exceeded while storing the job | QuotaExceededError | QuotaExceededError: "Quota exceeded." |
| More than 5 active fetches for the origin | not specified | TypeError: "There are too many active fetches for this origin." |
| Called inside a service worker (Chrome 149+ default) | not specified | NotAllowedError: "backgroundFetch.fetch() is not allowed in service worker environments." |
| Called in a fenced frame | not specified | NotAllowedError: "backgroundFetch is not allowed in fenced frames." |
Two consequences: check error.name rather than instanceof DOMException (most failures are plain TypeErrors), and treat a TypeError as "fall back to another download path", after checking with get(id) whether another tab won a race for the same id.
Where you may call fetch(): pages, not service workers¶
BackgroundFetchManager is exposed in windows and workers, and the spec lets a service worker start a background fetch (for example in response to a push message). Chromium closed that door in 2026: BackgroundFetchManager::fetch() now throws a NotAllowedError ("backgroundFetch.fetch() is not allowed in service worker environments.") when called from a service worker, unless the origin is on an allowlist that Google controls through a feature parameter, and the matching enterprise policy, RestrictBackgroundFetchFromServiceWorkerEnabled, is listed for Chrome 149 and later (desktop, ChromeOS and Android). The restriction is active when the policy is enabled or unset; administrators can disable the policy to lift it. Chrome's policy documentation describes the policy as temporary and slated for removal after M152. get() and getIds() in the worker are unaffected, and so are the events.
Even before that change, background fetches started from a worker or an iframe began paused in Chrome (see the permission section). Design for one flow: the user clicks a download button in a page, and the page calls fetch().
get(id) and getIds()¶
getIds() resolves with the ids of the registration's active fetches: those still downloading, paused, or waiting for their terminal event to finish. A job disappears from the list once it has ended. get(id) resolves with the BackgroundFetchRegistration for an active id, or undefined otherwise. The spec requires that, within one realm, the same BackgroundFetchRegistration object is returned for the same job every time (its "get a BackgroundFetchRegistration instance" algorithm), and orders get() so that the object it returns does not miss progress events.
These two methods are how a page re-attaches its progress UI after a reload or in a second tab, as resumeTracking() in the example below does.
BackgroundFetchRegistration¶
[Exposed=(Window,Worker)]
interface BackgroundFetchRegistration : EventTarget {
readonly attribute DOMString id;
readonly attribute unsigned long long uploadTotal;
readonly attribute unsigned long long uploaded;
readonly attribute unsigned long long downloadTotal;
readonly attribute unsigned long long downloaded;
readonly attribute BackgroundFetchResult result;
readonly attribute BackgroundFetchFailureReason failureReason;
readonly attribute boolean recordsAvailable;
attribute EventHandler onprogress;
Promise<boolean> abort();
Promise<BackgroundFetchRecord> match(RequestInfo request, optional CacheQueryOptions options = {});
Promise<sequence<BackgroundFetchRecord>> matchAll(optional RequestInfo request, optional CacheQueryOptions options = {});
};
enum BackgroundFetchResult { "", "success", "failure" };
enum BackgroundFetchFailureReason {
"", "aborted", "bad-status", "fetch-error", "quota-exceeded", "download-total-exceeded"
};
[Exposed=(Window,Worker)]
interface BackgroundFetchRecord {
readonly attribute Request request;
readonly attribute Promise<Response> responseReady;
};
BackgroundFetchRegistration attributes¶
| Attribute | Meaning |
|---|---|
id | The id passed to fetch() |
uploadTotal | Sum of all request body sizes, fixed when the job is created |
uploaded | Request body bytes sent so far |
downloadTotal | The downloadTotal option, or 0 |
downloaded | Response body bytes stored so far, across all requests |
result | "" while running, then "success" or "failure" |
failureReason | "" unless it failed (see the table below) |
recordsAvailable | true until the terminal service worker event has settled |
These values are snapshots: the spec copies them into the object so they can be read synchronously, and updates them in a task just before firing progress. Reading downloaded in a loop without awaiting anything will never show a change.
downloaded can go down. When a paused or interrupted request resumes, the browser asks for the remainder with a Range header; if the server answers with a full 200 instead of a matching 206, the partial bytes are thrown away and the request starts over, so the stored total shrinks. Progress UIs should tolerate that (the example clamps and simply re-renders).
Failure reasons¶
failureReason | Set when | Records afterwards |
|---|---|---|
"aborted" | The user cancelled in the browser UI, or abort() was called | Responses that completed are still readable in the abort event |
"bad-status" | A response had a status outside 200–299 | The error response is stored and readable; other requests still run to completion |
"fetch-error" | Network failure that could not be retried (non-GET), CORS or mixed-content failure, invalid partial response | responseReady rejects for that record |
"quota-exceeded" | Storing response bytes hit the origin's quota | Partial |
"download-total-exceeded" | Stored bytes exceeded downloadTotal | All requests are stopped immediately |
A job fails as a whole if any request fails, but only "download-total-exceeded" (and an abort) stops the other requests early; for the other reasons the remaining requests continue, so the fail event may still find usable responses. The example's onFetchFail() salvages them.
The progress event¶
progress is a plain Event fired at the BackgroundFetchRegistration whenever downloaded, uploaded, result or failureReason changes, in every realm (page or worker) that holds an object for that job. It stops once result is no longer empty, so the last progress event carries result: "success" or "failure". The spec carries an open issue to debounce it like mousemove, so do not build logic that depends on how often it fires; render the current values each time.
A result of "success" in the page does not mean your data is in Cache Storage yet: the service worker's backgroundfetchsuccess handler is only starting. Wait for a message from the worker (the example posts DOWNLOAD_COMPLETE) before offering "Play offline".
abort()¶
abort() resolves with true if the job was still active and is now being aborted, and false if it had already finished or been aborted. After a successful abort(), the backgroundfetchabort event fires in the service worker. The spec handles a race explicitly: if one request fails at the same moment abort() succeeds, the job still ends as aborted, so the true you received stays truthful.
match(), matchAll() and BackgroundFetchRecord¶
matchAll(request?, options?) returns a BackgroundFetchRecord for each request of the job that matches request (all of them if omitted), using the same matching rules and CacheQueryOptions (ignoreSearch, ignoreMethod, ignoreVary) as the Cache API. match() returns the first match or undefined.
Each record has:
request: a copy of the originalRequest, including its body.responseReady: a promise for theResponse. Per the spec it resolves as soon as that response's headers are available, with a body that streams from the browser's storage as bytes arrive; Chromium resolves it once that individual request has completed. Either way you can read finished records before the whole job ends. It rejects with anAbortErrorDOMException if the job was aborted before that response arrived. When a request produced no exposable response (a network or CORS failure), the spec rejects with aTypeError; Chromium currently rejects with anUnknownErrorDOMException ("The response is not available.").
The browser removes the Content-Range and Content-Length headers from these responses (a resumed download is reassembled from several partial responses, so the network's values would describe the wrong bytes).
Both methods reject with InvalidStateError once recordsAvailable is false; Chromium's message is "The records associated with this background fetch are no longer available." That happens as soon as the terminal event's waitUntil() promises settle, which is why the copy into Cache Storage has to happen inside that event.
Service worker events¶
partial interface ServiceWorkerGlobalScope {
attribute EventHandler onbackgroundfetchsuccess;
attribute EventHandler onbackgroundfetchfail;
attribute EventHandler onbackgroundfetchabort;
attribute EventHandler onbackgroundfetchclick;
};
[Exposed=ServiceWorker]
interface BackgroundFetchEvent : ExtendableEvent {
constructor(DOMString type, BackgroundFetchEventInit init);
readonly attribute BackgroundFetchRegistration registration;
};
[Exposed=ServiceWorker]
interface BackgroundFetchUpdateUIEvent : BackgroundFetchEvent {
constructor(DOMString type, BackgroundFetchEventInit init);
Promise<undefined> updateUI(optional BackgroundFetchUIOptions options = {});
};
| Event | Interface | Fires when | updateUI() |
|---|---|---|---|
backgroundfetchsuccess | BackgroundFetchUpdateUIEvent | Every request completed with an ok status | ✅ once |
backgroundfetchfail | BackgroundFetchUpdateUIEvent | At least one request failed (any reason except abort) | ✅ once |
backgroundfetchabort | BackgroundFetchEvent | The user or abort() cancelled the job | ❌ |
backgroundfetchclick | BackgroundFetchEvent | The user activated the browser's UI for the job, during or after the download | ❌ |
Exactly one of the first three fires per job. Each carries the job's BackgroundFetchRegistration as event.registration, with result and failureReason already set and the records still available. The worker may be started specifically for the event with no window open, so do not depend on page state.
updateUI()¶
updateUI({ title, icons }) changes the text and icon of the browser's UI for a finished job, typically from "Downloading Episode 5" to "Episode 5 is ready". Its rules:
- It may be called once per event; a second call rejects with
InvalidStateError(Chromium: "updateUI may only be called once."). - It must be called while the event is active, that is, synchronously in the handler or before the
waitUntil()promise settles; later calls reject withInvalidStateError("ExtendableEvent is no longer active."). - Only
titleandiconscan change. With neither, it resolves without doing anything.
Call it at the end of your handler, when you know whether the copy into Cache Storage worked, instead of immediately.
backgroundfetchclick¶
When the user taps the download notification (Android) or the item in the downloads UI (desktop), the browser fires backgroundfetchclick. Check event.registration.result to decide where to go: a finished job opens the content, a running one opens your downloads screen. Chromium treats this event like notificationclick: it grants the permission to call clients.openWindow() or WindowClient.focus() while the event is being handled, which ordinary events such as sync do not get.
Storing results in Cache Storage: a complete example¶
The example below implements "save episode for offline listening" for a podcast app. The page starts the job, shows progress, and falls back to an in-page download where Background Fetch is missing; the worker copies finished downloads into Cache Storage, salvages partial results on failure, and opens the right screen on click.
Page code¶
// downloads.js: page side of "save this episode for offline listening".
// `ui` is your view object: progress(state), error(text), onCancel(fn).
const ID_PREFIX = "episode:";
const EPISODE_CACHE = "episodes-v1";
export async function downloadEpisode(episode, ui) {
const registration = await navigator.serviceWorker.ready;
if (!("backgroundFetch" in registration)) return downloadInPage(episode, ui);
const id = ID_PREFIX + episode.id;
const manager = registration.backgroundFetch;
// Already running (started in another tab, or before a reload)? Attach.
const existing = await manager.get(id);
if (existing) return track(existing, ui);
try {
const bgFetch = await manager.fetch(id, [episode.audioUrl, episode.artworkUrl], {
title: episode.title,
icons: [{ src: episode.artworkUrl, sizes: "512x512", type: "image/jpeg" }],
// Exact decoded size from your API, or leave it out: a value that is too
// small aborts the whole fetch with failureReason "download-total-exceeded".
downloadTotal: episode.audioBytes + episode.artworkBytes,
});
return track(bgFetch, ui);
} catch (error) {
if (error.name === "QuotaExceededError") {
ui.error("There is not enough free storage for this episode.");
return null;
}
// Lost a race with another tab that used the same id: attach to its fetch.
const raced = await manager.get(id);
if (raced) return track(raced, ui);
// TypeError: downloads blocked for this site, invalid request, or too many
// active fetches for the origin. Fall back to a foreground download.
console.warn("[downloads] backgroundFetch.fetch() failed:", error.name, error.message);
return downloadInPage(episode, ui);
}
}
function track(bgFetch, ui) {
const render = () =>
ui.progress({
fraction: bgFetch.downloadTotal
? Math.min(bgFetch.downloaded / bgFetch.downloadTotal, 1)
: null, // unknown total: show an indeterminate indicator
downloaded: bgFetch.downloaded,
result: bgFetch.result, // "", "success" or "failure"
failureReason: bgFetch.failureReason,
});
bgFetch.addEventListener("progress", render);
ui.onCancel(() => bgFetch.abort()); // resolves true if it was still active
render();
return bgFetch;
}
// Call on page load: re-attach progress UI to fetches that are still running.
export async function resumeTracking(uiForEpisode) {
const registration = await navigator.serviceWorker.ready;
if (!("backgroundFetch" in registration)) return;
for (const id of await registration.backgroundFetch.getIds()) {
if (!id.startsWith(ID_PREFIX)) continue;
const bgFetch = await registration.backgroundFetch.get(id);
if (bgFetch) track(bgFetch, uiForEpisode(id.slice(ID_PREFIX.length)));
}
}
// Fallback: a foreground download that only survives while this page is open.
async function downloadInPage(episode, ui) {
const controller = new AbortController();
ui.onCancel(() => controller.abort());
const cache = await caches.open(EPISODE_CACHE);
let loaded = 0;
try {
await cache.add(episode.artworkUrl);
const response = await fetch(episode.audioUrl, { signal: controller.signal });
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const total = episode.audioBytes || Number(response.headers.get("Content-Length")) || 0;
const counter = new TransformStream({
transform(chunk, stream) {
loaded += chunk.byteLength;
ui.progress({
fraction: total ? Math.min(loaded / total, 1) : null,
downloaded: loaded,
result: "",
failureReason: "",
});
stream.enqueue(chunk);
},
});
// Store decoded bytes with a minimal header set: copying Content-Encoding
// or Content-Length from the network response would describe other bytes.
await cache.put(
episode.audioUrl,
new Response(response.body.pipeThrough(counter), {
headers: { "Content-Type": response.headers.get("Content-Type") || "audio/mpeg" },
})
);
ui.progress({ fraction: 1, downloaded: loaded, result: "success", failureReason: "" });
return null;
} catch (error) {
await Promise.all([cache.delete(episode.audioUrl), cache.delete(episode.artworkUrl)]);
ui.progress({
fraction: null,
downloaded: loaded,
result: "failure",
failureReason: error.name === "AbortError" ? "aborted" : "fetch-error",
});
return null;
}
}
// Background uploads: same API, a Request with a body.
export async function uploadInBackground(file) {
const registration = await navigator.serviceWorker.ready;
const key = crypto.randomUUID();
const request = new Request(`/api/uploads/${key}`, {
method: "PUT",
body: file, // read and stored by the browser before fetch() resolves
headers: {
"Content-Type": file.type || "application/octet-stream",
"Idempotency-Key": key,
},
});
return registration.backgroundFetch.fetch(`upload:${key}`, request, {
title: `Uploading ${file.name}`,
});
}
get(id)beforefetch(id)makes the button idempotent: clicking twice, or clicking in a second tab, attaches to the running job instead of failing.- After a failed
fetch(), a secondget(id)distinguishes "another tab just started it" from a real failure, so the fallback does not start a duplicate foreground download. - The fallback stores the decoded body with only a
Content-Typeheader. CopyingContent-LengthorContent-Encodingfrom the network response would describe bytes that are not in the cache. - Pair any offline-media feature with
navigator.storage.persist()and a storage estimate before starting; see Storage Quotas & Persistence.
Service worker¶
// sw.js: Background Fetch event handlers for episode downloads and uploads.
const EPISODE_CACHE = "episodes-v1";
const ID_PREFIX = "episode:";
const UPLOAD_PREFIX = "upload:";
self.addEventListener("backgroundfetchsuccess", (event) => {
// Every job of the origin arrives here: route by id prefix. Upload records
// are PUT requests, which Cache Storage refuses to store.
const { id } = event.registration;
if (id.startsWith(UPLOAD_PREFIX)) event.waitUntil(onUploadSettled(event));
else if (id.startsWith(ID_PREFIX)) event.waitUntil(onFetchSuccess(event));
});
self.addEventListener("backgroundfetchfail", (event) => {
const { id } = event.registration;
if (id.startsWith(UPLOAD_PREFIX)) event.waitUntil(onUploadSettled(event));
else if (id.startsWith(ID_PREFIX)) event.waitUntil(onFetchFail(event));
});
self.addEventListener("backgroundfetchabort", (event) => {
// Cancelled from the browser UI or by bgFetch.abort(). Nothing to store;
// the browser discards the downloaded bytes once this event settles.
event.waitUntil(broadcast({ type: "DOWNLOAD_ABORTED", id: event.registration.id }));
});
self.addEventListener("backgroundfetchclick", (event) => {
// One of the few events that may open or focus a window.
const bgFetch = event.registration;
const episodeId = bgFetch.id.startsWith(ID_PREFIX) ? bgFetch.id.slice(ID_PREFIX.length) : "";
const path =
bgFetch.result === "success" && episodeId
? `/episodes/${encodeURIComponent(episodeId)}`
: "/downloads";
event.waitUntil(openOrFocus(path));
});
async function onFetchSuccess(event) {
const bgFetch = event.registration;
const cache = await caches.open(EPISODE_CACHE);
const records = await bgFetch.matchAll(); // must happen inside this event
let title = "Ready to play offline";
let message = { type: "DOWNLOAD_COMPLETE", id: bgFetch.id };
try {
// Sequential on purpose: each put() streams a potentially large body to
// disk, and parallel copies multiply peak storage use.
for (const record of records) {
const response = await record.responseReady;
await cache.put(record.request, response);
}
} catch (error) {
// Usually QuotaExceededError: the download succeeded but the copy into
// Cache Storage did not. Remove partial entries so the UI stays honest.
await Promise.all(records.map((record) => cache.delete(record.request)));
title =
error.name === "QuotaExceededError"
? "Not enough storage to save this episode"
: "The episode could not be saved";
message = { type: "DOWNLOAD_FAILED", id: bgFetch.id, reason: error.name };
}
await event.updateUI({ title }); // allowed once, while the event is active
await broadcast(message);
}
async function onFetchFail(event) {
const bgFetch = event.registration;
const cache = await caches.open(EPISODE_CACHE);
let saved = 0;
// Records that completed can still be read: "bad-status" responses resolve
// (with their error status), network failures reject.
for (const record of await bgFetch.matchAll()) {
try {
const response = await record.responseReady;
if (response.ok) {
await cache.put(record.request, response);
saved += 1;
}
} catch {
/* this request never produced a usable response */
}
}
await event.updateUI({ title: describeFailure(bgFetch.failureReason) });
await broadcast({
type: "DOWNLOAD_FAILED",
id: bgFetch.id,
reason: bgFetch.failureReason,
saved,
});
}
async function onUploadSettled(event) {
const bgFetch = event.registration;
let status = 0;
let body = null;
try {
// One request per upload job; its record holds the server's reply.
const [record] = await bgFetch.matchAll();
const response = await record.responseReady;
status = response.status;
body = response.headers.get("Content-Type")?.includes("application/json")
? await response.json()
: null;
} catch {
/* network failure: no response to read */
}
const ok = bgFetch.result === "success";
await event.updateUI({ title: ok ? "Upload complete" : "Upload failed" });
await broadcast({
type: ok ? "UPLOAD_COMPLETE" : "UPLOAD_FAILED",
id: bgFetch.id,
status,
reason: bgFetch.failureReason,
body,
});
}
function describeFailure(reason) {
switch (reason) {
case "bad-status":
return "Download failed: the server returned an error";
case "fetch-error":
return "Download failed: network or security error";
case "quota-exceeded":
return "Download failed: not enough storage";
case "download-total-exceeded":
return "Download failed: file larger than expected";
default:
return "Download failed";
}
}
async function openOrFocus(path) {
const target = new URL(path, self.location.origin).href;
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const existing = windows.find((client) => new URL(client.url).origin === self.location.origin);
if (!existing) return self.clients.openWindow(target);
await existing.focus();
if (existing.url !== target) {
// navigate() rejects for clients this worker does not control; the page
// can route itself on this message instead.
await existing.navigate(target).catch(() => existing.postMessage({ type: "OPEN", path }));
}
return existing;
}
async function broadcast(message) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) client.postMessage(message);
}
Why the handlers look like this:
- Copy inside the event.
matchAll()and eachresponseReadymust be awaited before thewaitUntil()promise settles, because the browser deletes the job's stored responses right after. - Sequential copies. Each
cache.put()streams a body to disk. Until the event settles, the data exists twice (in Background Fetch's storage and in Cache Storage), so copying one file at a time keeps the peak lower and makes a quota failure happen earlier and more cleanly. - Honest UI on quota failure. A download can succeed while the copy fails. Removing partial entries and saying so in
updateUI()avoids an "available offline" badge on content that is not there. - Salvage on failure. In the fail event, completed records are still readable: a failed artwork request should not throw away a successfully downloaded audio file. Whether a partial result is useful is an app decision.
- One
updateUI()call, at the end. - Route by id. The terminal events fire for every job of the service worker registration, so the handlers dispatch on the id prefix. Upload jobs (
upload:…) must not go through the episode path: their records arePUTrequests, andcache.put()rejects any request whose method is notGETwith aTypeError.
Serving the stored media¶
Audio and video elements request media with Range headers, and a plain cache.match() returns the full 200 response, which some players handle poorly and which defeats seeking in large files. Answer range requests from the cached body; Caching Strategies shows the slicing approach, and Workbox packages it as workbox-range-requests. The pitfalls of caching partial responses are covered in Pitfalls & Anti-Patterns.
Uploads with Background Fetch¶
Background Fetch is not download-only. A Request with a body, as in uploadInBackground() above, becomes a background upload: the browser reads and stores the body before fetch() resolves (so the page can close immediately), uploadTotal is the body size, and uploaded reports progress. The spec's rules make uploads less robust than downloads, though:
- The browser UI may offer pause only if every request is a
GET, because "reissuing the request may have unwanted side effects". - When a non-
GETrequest hits a network error, it is not retried when the connection returns (onlyGETrequests wait for the resource to become available again). The job fails with"fetch-error". - The response to the upload is available as a record like any other response.
Use an idempotency key (the example puts it in both the URL and an Idempotency-Key header, see Background Sync) so that a retry of a failed job, started by the user, cannot create a duplicate. For uploads that must survive flaky networks, a resumable protocol (chunked uploads with server-side offsets) driven from the page is more robust than one large background request.
Cross-origin uploads are a further trap in current Chromium: the implementation rejects any request that would need a CORS preflight (custom headers, methods other than GET/HEAD/POST, non-simple content types) with "fetch-error", and does not expose cross-origin response bodies that fail the CORS check. Keep uploads same-origin.
Chromium implementation details¶
Beyond the spec, these behaviors of Chrome and Edge shape real deployments:
- UI. On Android, a background fetch appears as a download notification with your title, icon and progress, and the user can pause or cancel it there. On desktop, it appears as an item in the browser's downloads UI (the download bubble), backed by the same "offline items" machinery as regular downloads. The UI shows your origin prominently, as the spec requires.
- Permission = Automatic downloads. The
background-fetchpermission is derived from the download permission. For a call from a top-level page, Chrome uses the tab's download state: allowed (the normal state for a first download) starts the job, "prompt before download" starts it paused until the user resumes it in the UI, and "downloads blocked" rejectsfetch(). For calls from iframes or workers, it uses the Automatic downloads site setting but never grants more than "ask", so such jobs always started paused (a deliberate privacy decision, crbug.com/41421247). - Concurrency. Each origin may have at most 5 active fetches. The scheduler runs at most 2 jobs at a time, from different origins, and at most 2 requests at a time across them; further jobs and requests queue.
- Persistence. Jobs survive browser restarts (DevTools logs "Background Fetch resuming after browser restart"). Chrome's announcement of the API notes that on some platforms, such as Android, the browser can hand the transfer to the operating system and close.
- Offline and flaky networks. Per Chrome's announcement of the API, a fetch started offline, or one that loses connectivity, is paused and resumed later.
- Storage. Downloaded bytes are stored in the browser on behalf of your origin and count toward its quota, which is how
"quota-exceeded"can happen mid-download. Clearing site data or unregistering the service worker deletes active jobs. - Security fixes in progress. Because Background Fetch requests historically bypassed some checks that apply to
fetch(), Chromium plans to enforce CORS (and with it CORP, COEP and related policies) and Local Network Access permissions for them. Chrome Platform Status lists both for Chrome 154 (status "Proposed" as of September 2026). After that, a background fetch to a cross-origin CDN needs the same CORS headers a normalfetch()would.
Fallbacks for browsers without Background Fetch¶
Firefox, Safari, WebView, and Chromium users whose downloads are blocked all need another path. The options, in increasing robustness:
- Foreground download with progress, as
downloadInPage()does:fetch()plus a countingTransformStream, streamed intocache.put(). Tell the user to keep the app open, and consider the Screen Wake Lock API on long downloads. - Resumable chunked download: fetch the file in
Rangechunks (for example 8 MB each), store each chunk as it completes (in the Origin Private File System or as separate cache entries), record progress in IndexedDB, and resume from the last complete chunk on the next launch. This survives tab closure in the sense that progress is not lost, which is often what users actually need. - Streaming instead of storing: for media, streaming with HTTP caching may be good enough, and needs no download UI.
On iOS and iPadOS there is no background transfer for web apps at all; downloads happen while the app is in the foreground. See iOS & iPadOS for storage limits that affect large offline media there.
Testing and debugging¶
- Record events. In Chrome DevTools, Application > Background services > Background fetch records "Background Fetch registered" (with Total Requests and Start Paused), "Background Fetch started", "Request processing started" and "Request processing completed" (with Response Status and Response Size (bytes)), and "Background Fetch completed" with the Event Type that was dispatched (for example
BackgroundFetchSuccessEvent). Recording continues for up to three days with DevTools closed. Start Paused: Yes is the tell-tale sign of the permission falling back to "ask". - Inspect the result. After success, Application > Cache storage shows what your handler stored; comparing it with the record list catches a handler that finished too early.
- Simulate interruptions. Disconnect the network mid-download (a real disconnect, or a throttled local server you stop and restart) and confirm the job pauses and resumes, and that your server answers
Rangerequests with206and a correctContent-Range; otherwise resumption restarts from zero. - Test every terminal event. Point one request at a URL that returns 404 (
"bad-status"), setdownloadTotaltoo low ("download-total-exceeded"), and cancel from the browser UI ("aborted"). - Click in automation. The spec defines a WebDriver extension command,
POST /session/{session id}/backgroundfetch/{id}/click, that firesbackgroundfetchclickfor the newest job with that id. - Watch the worker console. Open the service worker's own DevTools (from Application > Service workers) before the job finishes; the terminal event may start the worker when no page is open, and its logs only appear there.
See Browser DevTools for the panels in general.
Browser support¶
Support data as of September 2026. For live data, see MDN's BackgroundFetchManager compatibility table and caniuse: BackgroundFetchManager.
| Feature | Chrome / Edge | Firefox | Safari (macOS and iOS) | Samsung Internet | Android WebView |
|---|---|---|---|---|---|
ServiceWorkerRegistration.backgroundFetch, fetch(), get(), getIds() | ✅ 74 / ✅ 79 ⚠️ | ❌ | ❌ | ✅ 11.0 | ❌ |
BackgroundFetchRegistration (progress, abort(), match(), matchAll()), BackgroundFetchRecord | ✅ 74 / ✅ 79 | ❌ | ❌ | ✅ 11.0 | ❌ |
backgroundfetchsuccess, backgroundfetchfail, backgroundfetchabort, backgroundfetchclick, updateUI() | ✅ 74 / ✅ 79 | ❌ | ❌ | ✅ 11.0 | ❌ |
| Request bodies (background uploads) | ✅ | ❌ | ❌ | ✅ | ❌ |
⚠️ Calling fetch() from a service worker rejects with NotAllowedError by default in Chrome 149 and later (enterprise policy RestrictBackgroundFetchFromServiceWorkerEnabled); for CORS and Local Network Access enforcement, Chrome Platform Status lists enforcement for Chrome 154.
Opera supports the API from version 62. The origin trial that preceded the Chrome 74 launch (Chrome 71) only allowed requests without a body. MDN marks the API experimental because only one engine implements it; WebKit tracks it in standards-positions issue 149 (concerns: privacy, maintenance) with no position, and Mozilla's request, standards-positions issue 30, is still labeled "under review" without a position.
Common pitfalls¶
- Copying responses after the event. Storing records from a
setTimeout, a later message or a page after the terminal event rejects withInvalidStateError; the data is gone. - A wrong
downloadTotal. Too low fails the whole job; the value is the decoded body size, not the compressed transfer size. - Treating
result: "success"in the page as "available offline". The worker is still copying; wait for its message. - Starting fetches from the service worker. Chromium now rejects them by default, and they used to start paused anyway. Start from a user action in a page.
- Reusing ids carelessly. A second
fetch()with the id of a running job rejects;get()first. no-corsrequests and opaque resources. Background Fetch refusesno-cors; cross-origin files need CORS headers, and in current Chromium must not need a preflight.- Servers that ignore
Range. Resumption silently restarts from zero,downloadeddrops, and a flaky connection may never finish a large file. - Calling
updateUI()twice or late. Only once, and only while the event is active. - Surprise downloads. Every background fetch is visible in the browser UI; start them only for downloads the user asked for.
- No fallback. Most users are on engines without the API, and Chromium's own commitment to it is limited.
Further reading¶
On this site
- Background Sync: deferred writes, retries and idempotency keys
- Periodic Background Sync: scheduled refresh for installed apps
- Cache Storage API:
cache.put(), matching rules and storage behavior - Storage Quotas & Persistence: budgeting for large offline media
- Origin Private File System: storing large binary files and resumable chunks
- Precaching & Runtime Caching: what not to precache and when to download on demand
- Messaging & the Clients API: reporting job status to pages and opening windows
- Caching Strategies: serving range requests from cached media
External references
- Background Fetch specification (WICG) and explainer
- MDN: Background Fetch API, BackgroundFetchManager, BackgroundFetchRegistration and BackgroundFetchUpdateUIEvent.updateUI()
- Chrome for Developers: Introducing Background Fetch
- Chrome Platform Status: Background Fetch API, CORS enforcement for Background Fetch and Local Network Access restrictions for Background Fetch
- blink-dev: Intent to deprecate Background Fetch (November 2025)
- Chrome Enterprise policy: RestrictBackgroundFetchFromServiceWorkerEnabled
- Chromium source: background_fetch_permission_context.cc and background_fetch_manager.cc
- Microsoft Edge: Synchronize and update a PWA in the background