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.

Background Fetch requires a registered service worker to function. Ensure your PWA has an active service worker before using this component.

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

{# 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

// 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

Always check for the unsupported event and provide fallback behavior for browsers that don't support the Background Fetch API.

<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

// 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

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>

</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

Starts a background fetch for one or more files.

Parameters:

  • id (string, required): Unique identifier for this download

  • url (string or array, required): URL(s) to download

  • title (string, optional): Title shown in system UI

  • icons (array, optional): Icons for system notifications

  • downloadTotal (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

Cancels 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

Lists all current and completed background fetches. This action is automatically called on controller connection.

// Manually trigger list refresh
host.controller.list();

Methods

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)

Retrieves a downloaded file from IndexedDB and opens or downloads it.

Parameters:

  • event.params.url (string): URL of the stored file

  • event.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)

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()

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

Fired 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

Fired when a background fetch starts successfully.

Event detail:

  • id: Download ID

  • urls: Array of URLs being downloaded

  • title: Download title

  • downloadTotal: 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

Fired periodically during download to report progress.

Event detail:

  • id: Download ID

  • downloaded: Bytes downloaded so far

  • downloadTotal: 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

Fired when a download completes successfully and is stored.

Event detail:

  • id: File URL

  • name: Filename

  • size: File size in bytes

  • contentType: MIME type

host.addEventListener('background-fetch:completed', (e) => {
  console.log(`Download completed: ${e.detail.name} (${e.detail.size} bytes)`);
});

failed

Fired when a download fails.

Event detail:

  • id: Download ID

  • downloaded: Bytes downloaded before failure

  • downloadTotal: Total size

  • urls: Array of URLs

host.addEventListener('background-fetch:failed', (e) => {
  console.error(`Download failed: ${e.detail.id}`);
});

aborted

Fired 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

Fired when attempting to start a download with an ID that's already in use.

Event detail:

  • id: Download ID

  • urls: URLs of existing download

  • title: Title of existing download

  • downloadTotal: Total size

host.addEventListener('background-fetch:exists', (e) => {
  console.log(`Download ${e.detail.id} is already in progress`);
});

not-found

Fired 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

Fired when a download cannot be cancelled (already finished or failed).

Event detail:

  • id: Download ID

  • reason: Reason why cancellation was refused

host.addEventListener('background-fetch:cancel-refused', (e) => {
  console.log(`Cannot cancel: ${e.detail.reason}`);
});

Resources

Last updated

Was this helpful?