Modern Web Weekly #12

Modern Web Weekly #12

Staying up to date with the modern web

👋
Hello there! I'm Danny and this is Modern Web Weekly, your weekly update on what the modern web is capable of, Web Components, and Progressive Web Apps (PWA). I test modern web features and write about them in plain English to make sure you stay up to date.

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 BackgroundFetchRecords 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:

🔗
Got an interesting link for Modern Web Weekly?
Send me a DM on Twitter to let me know!