Modern Web Weekly #12
Staying up to date with the modern web
Downloading files in the background with Background Fetch
When you download a large file in a web app, you have to keep it open until the download finishes. If you close it before finishing, you lose the file you were downloading and you have to start all over.
This is obviously not a great user experience and this is exactly the problem that the Background Fetch API solves. It enables web apps to continue downloading in the background, even when the app is closed. When the app goes offline while downloads are running these will be paused and continued when the network is back. While downloading, progress is shown to the user, and running downloads can also be paused or canceled.
A great use case for Background Fetch is a podcast app that lets users download episodes to their devices. Since these files can be quite large, using Background Fetch for downloading lets users close the app while the episode continues to download. When the user returns to the app, the episode is downloaded and ready to be listened to.
Browser support
Background Fetch has been supported since Chrome 74 and has been added as a Feature Flag to Safari Tech Preview, although the functionality hasn't been implemented yet.
How Background Fetch works
Background Fetch is managed by the BackgroundFetchManager
which is accessed through the ServiceWorkerRegistration
object. The fetch
method of BackgroundFetchManager
starts the actual download and returns a Promise
that resolves to a BackgroundFetchRegistration
which represents an individual background fetch.
const registration = await navigator.serviceWorker.ready;
const bgFetch = await registration.backgroundFetch.fetch(
'episode-1',
[
'https://example.com/episodes/episode-1.mp3',
'https://example.com/episodes/artwork/episode-1.jpg
],
{
title: `My Podcast - episode 1`,
icons: [{
sizes: '64x64',
src: './src/img/media/my-podcast.jpeg',
type: 'image/jpeg',
}],
downloadTotal: 100 * 1024 * 1024,
});
The first argument 'episode-1'
is the ID by which this BackgroundFetchRegistration
can be identified. This can be an arbitrary string as long as it's unique, meaning there's no other active BackgroundFetchRegistration
with the same ID.
The second argument is an array of files that will be fetched as part of this Background Fetch. These can be Request
objects or strings that will be given as the input
argument to the Request()
constructor. This way, you can combine multiple requests in a single Background Fetch to download multiple files that are logically a single thing to the user. For example, a podcast can consist of an audio file and some artwork, and a movie can consist of multiple video files.
The third argument is an object with a title
that will be displayed in the progress dialog and icons
that the browser may use to display in the progress dialog as well. The downloadTotal
key is the estimated total size in bytes of all files to be downloaded and this will be used to indicate the download progress.
When the download has started, you can track progress by attaching an event handler for the progress
event to the BackgroundFetchRegistration
:
bgFetch.addEventListener('progress', () => {
if (!bgFetch.downloadTotal) return;
const {downloaded, downloadTotal} = bgFetch;
const percent = Math.round(downloaded / downloadTotal * 100);
console.log('progress', percent, bgFetch);
});
Tracking progress of running fetches
The previous example tracks progress of a fetch that was just started, but when the app is opened while some fetches are running in the background you want to "pick up" that progress and show it to the user. This means you need to get a list of the fetches that are in progress and attach an event handler to each one for the progress
event. This will then start to show the progress of each fetch that is running to the user.
You get the list of IDs of the fetches that are currently running through the getIds()
method of the BackgroundFetchManager
and then you can access each BackgroundFetchRegistration
through the get(id)
method, passing in the ID of the fetch. Then you attach the event handler just like you did for the fetch when it was started:
const registration = await navigator.serviceWorker.ready;
// get the IDs of all running fetches
const ids = await registration.backgroundFetch.getIds();
for(const id of ids) {
// get the fetch by its ID
const bgFetch = await registration.backgroundFetch.get(id);
if(bgFetch) {
// get the current progress to update the UI
const {downloaded, downloadTotal} = bgFetch;
const percent = Math.round(downloaded / downloadTotal * 100);
// attach the event handler for the progress event
bgFetch.addEventListener('progress', () => {
if (!bgFetch.downloadTotal) return;
const {downloaded, downloadTotal} = bgFetch;
const percent = Math.round(downloaded / downloadTotal * 100);
console.log('progress', percent, bgFetch);
});
}
}
Handling successful fetches
When the Background Fetch has been completed, the Service Worker will receive a backgroundfetchsuccess
event. It makes sense that the Service Worker receives this event and not the BackgroundFetchRegistration
since the fetch may be completed in the background when the app is not running.
You can get the BackgroundFetchRegistration
from the event and from there you can get each BackgroundFetchRecord
that represents an individual request and response of the Background Fetch.
You get all BackgroundFetchRecord
s with the getAll()
method of BackgroundFetchRegistration
or an individual one through its URL with the get(URL)
method.
The following example shows how to get each BackgroundFetchRecord
and store the responses in the cache:
self.addEventListener("backgroundfetchsuccess", (e) => {
// get the BackgroundFetchRegistration
const bgFetch = e.registration;
e.waitUntil(
(async () => {
// open the cache
const cache = await caches.open("episodes");
// get all records
const records = await bgFetch.matchAll();
// put each request/response pair in the cache
const promises = records.map(async (record) => {
const response = await record.responseReady;
await cache.put(record.request, response);
});
await Promise.all(promises);
// update the progress UI
e.updateUI({ title: "Episode ready to listen!" });
})(),
);
});
Handling errors
When any of the fetches failed, the Service Worker will receive a backgroundfetchfail
event for each failed fetch. A fetch is considered failed when at least one request in the fetch has failed to complete.
You can check the failureReason
property of the BackgroundFetchRegistration
to see what went wrong:
addEventListener("backgroundfetchfail", (e) => {
// get the BackgroundFetchRegistration
const bgFetch = e.registration;
// get the id and failureReason of the fetch
const {id, failureReason} = bgFetch;
console.error(`fetch ${id} failed, reason: ${failureReason}`);
e.updateUI({ title: "Could not download the episode" });
});
Clicking the progress dialog
The progress dialog is clickable and when it's clicked the Service Worker will receive a backgroundfetchclick
event. A common action is to open the page that shows all downloads for example:
addEventListener("backgroundfetchclick", async(e) => {
const registration = e.registration;
const clients = await self.clients.matchAll();
if (registration.result === "success") {
clients.openWindow("/episodes");
}
else {
clients.openWindow("/download-progress");
}
});
What does it all look like?
I added a demo to What PWA Can Do Today so you can check for yourself. Here's a screen recording of the demo on Android:
Send me a DM on Twitter to let me know!