Background Fetch
The Background Fetch component provides a powerful way to download large files in the background, even when the user closes your application. This component wraps the Background Fetch API and manages file downloads through the service worker, with automatic storage in IndexedDB for offline access. It handles download progress tracking, cancellation, and retrieval of completed downloads.
This component is particularly useful for:
Downloading large media files (videos, podcasts, high-resolution images)
Enabling offline access to content (movies, music, documents)
Bulk downloading of resources for offline use
Providing podcast or video download features
Creating offline-first applications with large assets
Building progressive web apps with downloadable content
Implementing reliable download managers within web applications
Handling file downloads that may take longer than the page lifetime
Browser Support
The Background Fetch API is a relatively new feature with limited but growing support.
Support level: Limited - Currently supported in Chromium-based browsers (Chrome, Edge, Opera) on Android and desktop. Not supported in Safari or Firefox as of early 2025.
Always check for Background Fetch API support before using this component. The component will emit an unsupported event when the API is not available.
Usage
Basic File Download
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'video-download-1',
url: '/assets/videos/tutorial.mp4',
title: 'Downloading Tutorial Video',
downloadTotal: 52428800
}) }}>
Download Tutorial Video (50MB)
</button>
<div id="download-status"></div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const status = document.getElementById('download-status');
host.addEventListener('background-fetch:unsupported', () => {
status.innerHTML = '<p class="text-red-600">Background downloads not supported in this browser</p>';
});
host.addEventListener('background-fetch:started', (e) => {
const { id, title } = e.detail;
status.innerHTML = `<p class="text-blue-600">Download started: ${title}</p>`;
});
host.addEventListener('background-fetch:in-progress', (e) => {
const { id, downloaded, downloadTotal } = e.detail;
const percent = ((downloaded / downloadTotal) * 100).toFixed(1);
status.innerHTML = `
<div class="progress-bar">
<div class="progress-fill" style="width: ${percent}%"></div>
</div>
<p>${percent}% (${(downloaded / 1024 / 1024).toFixed(1)}MB / ${(downloadTotal / 1024 / 1024).toFixed(1)}MB)</p>
`;
});
host.addEventListener('background-fetch:completed', (e) => {
const { id, name } = e.detail;
status.innerHTML = `<p class="text-green-600">Download completed: ${name}</p>`;
});
host.addEventListener('background-fetch:failed', (e) => {
status.innerHTML = `<p class="text-red-600">Download failed</p>`;
});
</script>
<style>
.progress-bar {
width: 100%;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4A90E2, #357ABD);
transition: width 0.3s ease;
}
</style>Multiple File Downloads
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'podcast-series',
url: [
'/podcasts/episode-1.mp3',
'/podcasts/episode-2.mp3',
'/podcasts/episode-3.mp3'
],
title: 'Downloading Podcast Series',
icons: [{ src: '/icons/podcast.png', sizes: '192x192' }],
downloadTotal: 157286400
}) }}>
Download All Episodes (150MB)
</button>
<div id="multi-download-status"></div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const status = document.getElementById('multi-download-status');
host.addEventListener('background-fetch:started', (e) => {
const { urls, title } = e.detail;
status.innerHTML = `
<div class="download-card">
<h3>${title}</h3>
<p>Files: ${urls.length}</p>
<div class="progress-container"></div>
</div>
`;
});
host.addEventListener('background-fetch:in-progress', (e) => {
const { downloaded, downloadTotal } = e.detail;
const percent = ((downloaded / downloadTotal) * 100).toFixed(1);
const container = document.querySelector('.progress-container');
container.innerHTML = `
<div class="progress-bar">
<div class="progress-fill" style="width: ${percent}%"></div>
</div>
<p class="text-sm">${percent}% complete</p>
`;
});
host.addEventListener('background-fetch:completed', (e) => {
status.innerHTML = `
<div class="download-card success">
<h3>Download Complete!</h3>
<p>All episodes are now available offline</p>
</div>
`;
});
</script>Download Management with Cancel
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<div id="download-manager">
<button id="start-download" {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'large-file',
url: '/assets/movie.mp4',
title: 'Downloading Movie',
downloadTotal: 1073741824
}) }}>
Start Download (1GB)
</button>
<button id="cancel-download" class="hidden" {{ stimulus_action('@pwa/background-fetch', 'cancel', {
id: 'large-file'
}) }}>
Cancel Download
</button>
</div>
<div id="manager-status"></div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const startBtn = document.getElementById('start-download');
const cancelBtn = document.getElementById('cancel-download');
const status = document.getElementById('manager-status');
host.addEventListener('background-fetch:started', () => {
startBtn.classList.add('hidden');
cancelBtn.classList.remove('hidden');
status.innerHTML = '<p>Download in progress...</p>';
});
host.addEventListener('background-fetch:in-progress', (e) => {
const { downloaded, downloadTotal } = e.detail;
const percent = ((downloaded / downloadTotal) * 100).toFixed(1);
status.innerHTML = `
<div class="flex items-center gap-4">
<div class="flex-1 bg-gray-200 rounded-full h-4">
<div class="bg-blue-600 h-4 rounded-full transition-all" style="width: ${percent}%"></div>
</div>
<span class="text-sm font-medium">${percent}%</span>
</div>
<p class="text-sm text-gray-600 mt-2">
${(downloaded / 1024 / 1024).toFixed(0)}MB / ${(downloadTotal / 1024 / 1024).toFixed(0)}MB
</p>
`;
});
host.addEventListener('background-fetch:aborted', () => {
startBtn.classList.remove('hidden');
cancelBtn.classList.add('hidden');
status.innerHTML = '<p class="text-yellow-600">Download cancelled</p>';
});
host.addEventListener('background-fetch:completed', () => {
startBtn.classList.add('hidden');
cancelBtn.classList.add('hidden');
status.innerHTML = '<p class="text-green-600">Download completed!</p>';
});
host.addEventListener('background-fetch:exists', (e) => {
status.innerHTML = '<p class="text-blue-600">This download is already in progress</p>';
});
</script>Retrieving Downloaded Files
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<h2>My Downloaded Files</h2>
<div id="files-list"></div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const filesList = document.getElementById('files-list');
// Listen for completed downloads
host.addEventListener('background-fetch:completed', (e) => {
addFileToList(e.detail);
});
function addFileToList(file) {
const fileDiv = document.createElement('div');
fileDiv.className = 'file-item p-4 border rounded mb-2 flex justify-between items-center';
fileDiv.innerHTML = `
<div>
<p class="font-semibold">${file.name || 'Downloaded File'}</p>
<p class="text-sm text-gray-600">${(file.size / 1024 / 1024).toFixed(2)}MB</p>
</div>
<div class="flex gap-2">
<button class="btn-open bg-blue-500 text-white px-4 py-2 rounded">Open</button>
<button class="btn-download bg-green-500 text-white px-4 py-2 rounded">Download</button>
<button class="btn-delete bg-red-500 text-white px-4 py-2 rounded">Delete</button>
</div>
`;
// Open file in new tab
fileDiv.querySelector('.btn-open').addEventListener('click', () => {
host.controller.get({
preventDefault: () => {},
params: { url: file.id }
});
});
// Download file
fileDiv.querySelector('.btn-download').addEventListener('click', () => {
host.controller.get({
preventDefault: () => {},
params: { url: file.id, name: file.name }
});
});
// Delete file
fileDiv.querySelector('.btn-delete').addEventListener('click', async () => {
await host.controller.delete({ params: { url: file.id } });
fileDiv.remove();
});
filesList.appendChild(fileDiv);
}
</script>Best Practices
Always Provide downloadTotal
Always specify the downloadTotal parameter when starting a background fetch. This allows the browser to display accurate progress information to the user in the system UI.
{# Good - with downloadTotal #}
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'file-1',
url: '/large-file.zip',
title: 'Downloading File',
downloadTotal: 104857600 {# 100MB #}
}) }}>Download</button>
{# Less ideal - without downloadTotal (progress will be indeterminate) #}
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'file-2',
url: '/unknown-size.zip',
title: 'Downloading File'
}) }}>Download</button>Use Unique IDs
Each background fetch requires a unique ID. If you attempt to start a fetch with an ID that's already in use, the component will emit an exists event instead of starting a new download.
// Generate unique IDs for downloads
const downloadId = `download-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Or use meaningful IDs based on content
const downloadId = `video-${videoId}`;Handle Browser Support Gracefully
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<button id="download-btn" data-url="/file.mp4">Download</button>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const btn = document.getElementById('download-btn');
let supportsBackgroundFetch = true;
host.addEventListener('background-fetch:unsupported', () => {
supportsBackgroundFetch = false;
});
btn.addEventListener('click', () => {
if (!supportsBackgroundFetch) {
// Fallback to regular download
window.location.href = btn.dataset.url;
}
});
</script>Manage Storage Space
Background-fetched files are stored in IndexedDB, which has storage limits. Implement cleanup strategies to manage storage space:
Delete old downloads when new ones complete
Provide UI for users to manually delete downloads
Monitor storage quota usage
// Example: Delete old files when storage is low
host.addEventListener('background-fetch:completed', async (e) => {
const files = await host.controller.getStoredFiles();
// If more than 10 files, delete oldest
if (files.length > 10) {
const oldestFile = files[0];
await host.controller.delete({ params: { url: oldestFile.id } });
console.log('Deleted old file to free space');
}
});Provide User Feedback
The Background Fetch API shows system-level notifications, but always provide in-app UI feedback as well for a better user experience.
Icons for Better UX
{# Provide icons for better visual feedback in system notifications #}
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'podcast-1',
url: '/podcasts/episode.mp3',
title: 'Downloading Podcast',
icons: [
{ src: '/icons/icon-72.png', sizes: '72x72', type: 'image/png' },
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }
],
downloadTotal: 52428800
}) }}>Download Episode</button>Common Use Cases
1. Podcast/Music Download Manager
</div>
### 2. Video Course Offline Access
<div data-gb-custom-block data-tag="code" data-lineNumbers='true'>
```twig
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<div class="course-section">
<h2>Download Entire Course</h2>
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'course-complete',
url: [
'/courses/video-1.mp4',
'/courses/video-2.mp4',
'/courses/video-3.mp4',
'/courses/resources.pdf'
],
title: 'Downloading Complete Course',
icons: [{ src: '/course-icon.png', sizes: '192x192' }],
downloadTotal: 524288000
}) }}>
Download All Lessons (500MB)
</button>
<div id="course-download-progress"></div>
</div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const progress = document.getElementById('course-download-progress');
host.addEventListener('background-fetch:started', (e) => {
progress.innerHTML = `
<div class="mt-4 p-4 bg-blue-50 rounded">
<p class="font-semibold">Downloading ${e.detail.urls.length} files...</p>
<div class="progress-bar-container mt-2"></div>
</div>
`;
});
host.addEventListener('background-fetch:in-progress', (e) => {
const percent = ((e.detail.downloaded / e.detail.downloadTotal) * 100).toFixed(1);
const mb = (e.detail.downloaded / 1024 / 1024).toFixed(0);
const totalMb = (e.detail.downloadTotal / 1024 / 1024).toFixed(0);
progress.querySelector('.progress-bar-container').innerHTML = `
<div class="w-full bg-gray-300 rounded-full h-6">
<div class="bg-blue-600 h-6 rounded-full flex items-center justify-center text-white text-sm"
style="width: ${percent}%">
${percent}%
</div>
</div>
<p class="text-sm mt-2">${mb}MB / ${totalMb}MB</p>
`;
});
host.addEventListener('background-fetch:completed', () => {
progress.innerHTML = `
<div class="mt-4 p-4 bg-green-50 rounded">
<p class="font-semibold text-green-700">✓ Course downloaded!</p>
<p class="text-sm">All lessons are now available offline</p>
</div>
`;
});
</script>3. Document Library Sync
<div {{ stimulus_controller('@pwa/background-fetch') }}>
<div class="library-header">
<h2>My Documents</h2>
<button id="sync-all-btn">Sync All for Offline</button>
</div>
<div id="documents-list"></div>
</div>
<script type="module">
const host = document.querySelector('[data-controller="pwa__background-fetch"]');
const syncBtn = document.getElementById('sync-all-btn');
syncBtn.addEventListener('click', async () => {
// Fetch list of documents from API
const response = await fetch('/api/documents');
const documents = await response.json();
// Calculate total size
const totalSize = documents.reduce((sum, doc) => sum + doc.size, 0);
// Start background fetch for all documents
const urls = documents.map(doc => doc.url);
syncBtn.dispatchEvent(new CustomEvent('background-fetch:download', {
bubbles: true,
detail: {
params: {
id: 'library-sync-' + Date.now(),
url: urls,
title: `Syncing ${documents.length} documents`,
icons: [{ src: '/icons/sync.png', sizes: '192x192' }],
downloadTotal: totalSize
}
}
}));
});
host.addEventListener('background-fetch:completed', () => {
alert('All documents are now available offline!');
});
</script>4. Media Gallery Offline Mode
</div>
## API Reference
### Values
#### `dbName`
**Type:** `String`
**Default:** `'bgfetch-completed'`
The name of the IndexedDB database used to store completed downloads.
<div data-gb-custom-block data-tag="code" data-lineNumbers='true'>
```twig
<div {{ stimulus_controller('@pwa/background-fetch', { dbName: 'my-custom-db' }) }}>
</div>Actions
download
downloadStarts a background fetch for one or more files.
Parameters:
id(string, required): Unique identifier for this downloadurl(string or array, required): URL(s) to downloadtitle(string, optional): Title shown in system UIicons(array, optional): Icons for system notificationsdownloadTotal(number, optional but recommended): Total download size in bytes
<button {{ stimulus_action('@pwa/background-fetch', 'download', {
id: 'unique-id',
url: '/file.mp4',
title: 'Downloading Video',
icons: [{ src: '/icon.png', sizes: '192x192' }],
downloadTotal: 52428800
}) }}>Download</button>cancel
cancelCancels an in-progress background fetch.
Parameters:
id(string, required): ID of the download to cancel
<button {{ stimulus_action('@pwa/background-fetch', 'cancel', { id: 'download-id' }) }}>
Cancel
</button>list
listLists all current and completed background fetches. This action is automatically called on controller connection.
// Manually trigger list refresh
host.controller.list();Methods
has(params)
has(params)Checks if a file with the given URL is stored in IndexedDB.
Parameters:
params.url(string): URL to check
Returns: Promise<boolean>
const isStored = await host.controller.has({ params: { url: '/file.mp4' } });
if (isStored) {
console.log('File is available offline');
}get(event)
get(event)Retrieves a downloaded file from IndexedDB and opens or downloads it.
Parameters:
event.params.url(string): URL of the stored fileevent.params.name(string, optional): Filename for download (if omitted, file opens in new tab)
// Open in new tab
host.controller.get({
preventDefault: () => {},
params: { url: '/file.mp4' }
});
// Download with custom filename
host.controller.get({
preventDefault: () => {},
params: { url: '/file.mp4', name: 'my-video.mp4' }
});delete(params)
delete(params)Deletes a stored file from IndexedDB.
Parameters:
params.url(string): URL of the file to delete
await host.controller.delete({ params: { url: '/file.mp4' } });
console.log('File deleted from storage');getStoredFiles()
getStoredFiles()Returns all files stored in IndexedDB.
Returns: Promise<Array>
const files = await host.controller.getStoredFiles();
console.log(`You have ${files.length} downloaded files`);
files.forEach(file => {
console.log(`${file.name}: ${file.size} bytes`);
});Targets
None
Events
unsupported
unsupportedFired when the Background Fetch API is not supported by the browser.
host.addEventListener('background-fetch:unsupported', () => {
console.log('Background Fetch not supported');
// Implement fallback behavior
});started
startedFired when a background fetch starts successfully.
Event detail:
id: Download IDurls: Array of URLs being downloadedtitle: Download titledownloadTotal: Total size in bytes
host.addEventListener('background-fetch:started', (e) => {
console.log(`Download started: ${e.detail.title}`);
console.log(`Files: ${e.detail.urls.length}`);
});in-progress
in-progressFired periodically during download to report progress.
Event detail:
id: Download IDdownloaded: Bytes downloaded so fardownloadTotal: Total size in bytes
host.addEventListener('background-fetch:in-progress', (e) => {
const percent = (e.detail.downloaded / e.detail.downloadTotal) * 100;
console.log(`Progress: ${percent.toFixed(1)}%`);
});completed
completedFired when a download completes successfully and is stored.
Event detail:
id: File URLname: Filenamesize: File size in bytescontentType: MIME type
host.addEventListener('background-fetch:completed', (e) => {
console.log(`Download completed: ${e.detail.name} (${e.detail.size} bytes)`);
});failed
failedFired when a download fails.
Event detail:
id: Download IDdownloaded: Bytes downloaded before failuredownloadTotal: Total sizeurls: Array of URLs
host.addEventListener('background-fetch:failed', (e) => {
console.error(`Download failed: ${e.detail.id}`);
});aborted
abortedFired when a download is successfully cancelled.
Event detail:
id: Download ID
host.addEventListener('background-fetch:aborted', (e) => {
console.log(`Download cancelled: ${e.detail.id}`);
});exists
existsFired when attempting to start a download with an ID that's already in use.
Event detail:
id: Download IDurls: URLs of existing downloadtitle: Title of existing downloaddownloadTotal: Total size
host.addEventListener('background-fetch:exists', (e) => {
console.log(`Download ${e.detail.id} is already in progress`);
});not-found
not-foundFired when attempting to cancel a download that doesn't exist.
Event detail:
id: Download ID
host.addEventListener('background-fetch:not-found', (e) => {
console.log(`Download ${e.detail.id} not found`);
});cancel-refused
cancel-refusedFired when a download cannot be cancelled (already finished or failed).
Event detail:
id: Download IDreason: Reason why cancellation was refused
host.addEventListener('background-fetch:cancel-refused', (e) => {
console.log(`Cannot cancel: ${e.detail.reason}`);
});Related Components
Service Worker - Required for Background Fetch to function
BackgroundSync Queue - Queue requests for later execution
Sync Broadcast - Broadcast messages between tabs and service worker
Resources
Last updated
Was this helpful?