Background Fetch API: Keep Large Downloads Alive After Tab Closes
A normal fetch() is tied to the life of the page: close the tab or navigate away mid-download and the request dies. For small requests this is fine, but for large, long-running transfers — downloading a podcast episode, a movie for offline viewing, a large dataset, uploading a big video — it's a real problem. The user starts a 500MB download, switches apps (on mobile, the browser may be backgrounded or killed), and the download is lost; they have to start over, wasting time and data. The naive workarounds (keep the tab open, chunk-and-resume manually with localStorage bookkeeping) are fragile and bad UX. The Background Fetch API solves this: it hands a download (or upload) to the browser/OS to manage in the background, independent of the page — so the transfer continues even if the user closes the tab or leaves the app, the browser shows native download progress UI, and your service worker is notified when it completes. It's purpose-built for large transfers that must survive the page lifecycle. Understanding why normal fetch fails for this, how Background Fetch delegates to the browser, the service-worker completion flow, and where it fits is essential for offline-capable and media-heavy web apps.
The Problem: fetch Dies With the Page
Normal fetch can't handle large transfers that need to outlive the page:
| Aspect | Normal fetch | Background Fetch |
|---|---|---|
| Survives page close | no (request killed) | yes (browser-managed) |
| Survives app backgrounding | no | yes |
| Progress UI | you build it | native browser UI |
| Large transfers | fragile | purpose-built |
| Completion handling | in-page only | service worker notified |
| Resumable | manual chunking | browser handles it |
| Constraint | Detail | Consequence |
|---|---|---|
| Page-bound fetch | dies on tab close/navigate | large downloads lost |
| Service worker needed | completion via SW event | requires a service worker |
| Browser-managed | OS controls the transfer | you don't control timing precisely |
| Support | inconsistent (limited browsers) | progressive enhancement |
The problem: a normal fetch() is tied to the page — close the tab, navigate away, or (on mobile) have the app backgrounded/killed, and the in-flight request dies. For large transfers (media downloads/uploads, big datasets) this means lost progress and a bad experience. The Background Fetch API delegates the transfer to the browser/OS, which manages it independent of the page — so it continues after the page closes, shows native progress UI, and notifies your service worker on completion. It's specifically for large, long-running transfers that must survive the page lifecycle, filling the gap normal fetch can't.
Architecture: Delegate to the Browser, Notify the Service Worker
Page starts a background fetch:
registration = await serviceWorkerReg.backgroundFetch.fetch(
'download-id', ['/large-file.zip'], { title, downloadTotal, icons });
│ → hands the transfer to the BROWSER/OS
▼
┌──────────────────────────────────────────────────────────────────────┐
│ BROWSER/OS manages the transfer in the BACKGROUND │
│ • continues even if the PAGE CLOSES / app is backgrounded │
│ • shows NATIVE download progress UI (like an OS download) │
│ • resumable, OS-scheduled │
└───────────────────────────┬──────────────────────────────────────────┘
▼ on completion (page may be gone)
┌──────────────────────────────────────────────────────────────────────┐
│ SERVICE WORKER receives the event: │
│ 'backgroundfetchsuccess' → store the downloaded data (Cache/IDB) │
│ 'backgroundfetchfail' / 'backgroundfetchabort' → handle failure │
│ (the SW runs even when no page is open — that's how completion works)│
└──────────────────────────────────────────────────────────────────────┘
The flow: the page calls backgroundFetch.fetch() (on the service worker registration), handing the transfer to the browser/OS, which manages it in the background — continuing past page close, showing native progress UI, and resuming as needed. When it completes, the browser fires an event in the service worker (backgroundfetchsuccess/backgroundfetchfail), where you store the downloaded data (in Cache Storage or IndexedDB) — and crucially, the service worker can run even when no page is open, which is how completion is handled after the page is gone. The service worker dependency is fundamental: it's the persistent context that survives the page and receives the completion notification.
Starting and Tracking a Background Fetch
You initiate a background fetch from the service worker registration, giving it an ID, the URLs, and metadata for the native UI (title, total size, icon). It returns a registration you can track for progress while the page is open.
// Start a background fetch (from the page, via the service worker registration).
const swReg = await navigator.serviceWorker.ready;
const bgFetch = await swReg.backgroundFetch.fetch(
'movie-download', // unique ID for this fetch
['/movies/film.mp4'], // URL(s) to fetch (can be multiple)
{
title: 'Downloading film.mp4', // shown in native progress UI
downloadTotal: 500 * 1024 * 1024, // expected size (for accurate progress)
icons: [{ src: '/icon.png', sizes: '128x128', type: 'image/png' }],
},
);
// Track progress WHILE the page is open (optional — it continues regardless):
bgFetch.addEventListener('progress', () => {
const pct = Math.round((bgFetch.downloaded / bgFetch.downloadTotal) * 100);
updateProgressBar(pct);
});
// If the page closes here, the download CONTINUES in the background.
The metadata (title, downloadTotal, icons) feeds the browser's native download UI — so the user sees a system-level progress indicator (like any OS download), which persists even after your page closes. You can track progress in-page (the progress event) for richer UI while the page is open, but this is optional — the transfer continues independent of the page. The unique ID lets you reference/resume/abort the fetch later (even from a different page load). The key behavior: once started, the fetch is the browser's responsibility, not the page's — closing the page doesn't stop it.
Completion in the Service Worker
The defining feature: when the background fetch completes (success or failure), the browser fires an event in the service worker — which can run even with no page open — so you can store the downloaded data. This is how a download that finishes after the user closed the tab gets saved.
// In the service worker — handle completion (runs even with NO page open).
self.addEventListener('backgroundfetchsuccess', (event) => {
const bgFetch = event.registration;
event.waitUntil(async function () { // keep the SW alive until done
const cache = await caches.open('downloads');
const records = await bgFetch.matchAll();
for (const record of records) {
const response = await record.responseReady; // the downloaded data
await cache.put(record.request, response); // store it (Cache/IndexedDB)
}
// optionally show a notification: "Download complete"
await self.registration.showNotification('Download complete');
}());
});
self.addEventListener('backgroundfetchfail', (event) => {
// partial/failed download — clean up or retry
});
self.addEventListener('backgroundfetchabort', (event) => {
// user cancelled via the native UI
});
The service worker is essential because it's the persistent context that outlives the page — when the download finishes (possibly long after the page closed, possibly while the app is backgrounded), the browser wakes the service worker to fire backgroundfetchsuccess, where you store the data (in Cache Storage or IndexedDB). The event.waitUntil() keeps the service worker alive until you've finished storing (the same pattern as other service worker events). Without a service worker, there'd be no way to handle completion after the page is gone — which is exactly the gap Background Fetch fills. The backgroundfetchfail/backgroundfetchabort events handle failure and user cancellation (via the native UI).
Production Realities and Incidents
Incident 1: The Lost Large Download
A media app used normal fetch() to download large files for offline viewing; users who switched apps or closed the tab mid-download lost all progress and had to restart. Root cause: normal fetch dies with the page. Fix: Background Fetch — the browser manages the transfer independent of the page, so it survives backgrounding/closing and resumes. For large transfers that must complete regardless of the page lifecycle, Background Fetch is the right tool; normal fetch is page-bound.
Incident 2: The Missing Service Worker
A team tried to use Background Fetch but didn't have a (properly registered) service worker, so completion handling failed — the download finished but the data was never stored. Root cause: Background Fetch completion is delivered via service worker events; without a service worker, there's no way to handle a fetch that completes after the page closes. Fix: register a service worker and handle backgroundfetchsuccess. Background Fetch fundamentally depends on a service worker for its core value (post-page-close completion).
Incident 3: The Unsupported-Browser Crash
An app called backgroundFetch.fetch() directly; on browsers without Background Fetch support, it threw and broke the feature. Root cause: Background Fetch support is inconsistent (limited browser availability). Fix: feature-detect ('backgroundFetch' in swReg) and fall back to normal fetch (with the page kept open) where unsupported. Background Fetch is a progressive enhancement; provide a fallback.
Tradeoffs and Engineering Decisions
- Background Fetch vs normal fetch. Background Fetch survives page close/backgrounding, shows native UI, and is resumable — ideal for large/long transfers that must complete regardless of the page. Normal fetch is simpler and fine for small/short requests but dies with the page. Use Background Fetch only for large transfers needing page-independence; normal fetch for everything else (it's simpler and universally supported).
- Browser-managed vs app-controlled. Delegating to the browser/OS gives robust background transfer (survives page close, native UI, OS scheduling) but cedes precise control (you don't control exactly when it runs — the OS schedules it) and requires handling completion asynchronously in the service worker. Manual chunked downloads give full control but are fragile and don't survive page close. For large transfers, browser-managed robustness wins.
- Service worker dependency. Requiring a service worker enables post-page-close completion (the SW outlives the page) — the core value — but adds the complexity of a service worker and its lifecycle. Worth it for the page-independence; if you don't need transfers to survive the page, you don't need Background Fetch (or its SW dependency).
- Progressive enhancement. Because support is inconsistent (limited browsers), Background Fetch must be a progressive enhancement (feature-detect, fall back to normal fetch). Don't depend on it as the only download path; enhance where available, degrade gracefully elsewhere.
Key Takeaways
- A normal
fetch()dies with the page (tab close, navigation, app backgrounding) — losing large in-flight transfers; the Background Fetch API hands the transfer to the browser/OS to manage independent of the page, so it survives the page closing, shows native progress UI, and resumes. - It's purpose-built for large, long-running transfers (media downloads for offline, big uploads/datasets) that must complete regardless of the page lifecycle.
- Completion is delivered via service worker events (
backgroundfetchsuccess/fail/abort) — the service worker runs even when no page is open, which is how a download that finishes after the user closed the tab gets stored (in Cache Storage/IndexedDB). A service worker is required. - Start it via
swReg.backgroundFetch.fetch(id, urls, { title, downloadTotal, icons })(the metadata drives the native UI); trackprogressin-page optionally, but the transfer continues independent of the page once started. - It's a progressive enhancement with inconsistent support — feature-detect (
'backgroundFetch' in swReg) and fall back to normal fetch where unsupported. - Use Background Fetch for large transfers needing page-independence; normal fetch for small/short requests (simpler, universally supported) — and accept that browser-managed transfer trades precise control for robustness.
What did you think?