Progressive Web Apps: Using Service Workers to Create an Offline Page
May 18, 2017
  • Jeroen Savat

  • Frontend Software Engineer

As you may have heard, one of the main benefits of a Progressive Web App is offline accessibility (as mentioned in Three Reasons Why Progressive Web Apps are the Future). What makes “offline first” possible is a new browser feature called a “Service Worker” (SW). Service Workers can be used to cache requests and resources (among other things). But the cool thing is that a Service Worker will run on its own (it even runs outside of the browser environment; this makes receiving push notifications possible, even when the browser isn’t running).

In this article we will create a Service Worker file based on a Custom Offline Page Sample (SW) by Google. Although the code itself is well documented, it does assume a lot of knowledge that may not be so obvious to someone who is new to Service Workers. This article will therefore explain the lifecycle of Service Workers by guiding you through the code step by step. In doing so, we will clarify the theory behind the Service Worker mechanism.

The strategy of the sample used here is to install itself whenever a user first visits your website. When the Service Worker activates, it will download an offline page resource on installation; when it later detects that the user does not have an Internet connection, it will show a cached offline page. Sounds simple enough, right?

Here’s a high level overview of how the service worker relates to the Page, Network and Cache:

Service Workers

Creating a Service Worker file

Before we start, there are some requirements to serve a Service Worker file:

  • Your site has to be HTTPS or the browser will refuse to install a Service Worker.
  • Your Service Worker JavaScript file has to be served with an “application/javascript”-MIME type. Ask your local DevOps to ensure your server responds with the correct header, to avoid this error: Uncaught (in promise) DOMException: Failed to register a ServiceWorker: The script has an unsupported MIME type ('text/html').

In this file, we will make our Service Worker cache an offline page. If we want to be able to update this page in the future, we need a versioning strategy for our cache (because if we don’t, we may not be able to replace old cached versions for the next 24 hours).

To do this, we first assign a version number to our cache. Additionally, we also want to define what page it is that we want to cache (see the code example below).

let CURRENT_CACHES = {
  offline: 'offline-v1';
};
const OFFLINE_URL = 'offline.html';

The first event that fires in the lifecycle of a Service Worker is the install event. Note: If there is an existing Service Worker available and the new version is any different, the new version will be installed in the background, but not yet activated.

self.addEventListener('install', event => {
  event.waitUntil(
    fetch(createCacheBustedRequest(OFFLINE_URL)).then(function(response) {
      return caches.open(CURRENT_CACHES.offline).then(function(cache) {
        return cache.put(OFFLINE_URL, response);
      });
    })
  );

function createCacheBustedRequest(url) {
  let request = new Request(url, {cache: 'reload'});
  if ('cache' in request) {
    return request;
  }
  let bustedUrl = new URL(url, self.location.href);
  bustedUrl.search += (bustedUrl.search ? '&' '') + 'cachebust=' + Date.now();
  return new Request(bustedUrl);
}
});

The self keyword (see above) is a way to reference your Service Worker and to add listeners to it. Inside of our install callback, we take the following steps:

  • With Fetch API (a “new” API), we first make a call to our offline page, all the while ensuring that the URL to this resource is unique. To do this, we use a helper function called createCacheBustedRequest. This function ensures that we can later update our offline page with a new version.
  • Secondly, we call caches.open() with our desired cache name, and then chain two promises (caches.open() and cache.put()). In doing so we pass our resource and effectively put it into our named cache.

The event.waitUntil method takes a promise and uses it to confirm whether all the required assets are already cached or not. Important to note is that the Service Worker will only be installed when all the files are successfully cached. If any of the files fail to download, then the install step will fail. This means that you need to be very careful with the files you decide to cache in the install step.

Another “new” API that makes it possible to cache requests and their responses is the Cache API. Though this API is not part of the Service Worker spectrum itself, it is frequently used in combination with a Service Worker (which is why we are mentioning it here).

A Service Worker can only be activated if all the tabs in which an earlier version still exists are closed. In our activate event, we delete outdated, unused caches (you can determine this by their version number - if the two version numbers do not match, you’ll know the cache is unused). The reason we do this is that each browser has a hard limit on the amount of cache storage that a given domain is able to use. If the limit is reached, caches could be deleted, including caches with useful data.

self.addEventListener('activate', event => {
  let expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
    return CURRENT_CACHES[key];
  });
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (expectedCacheNames.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

After a Service Worker is installed and the user navigates to a different page, or refreshes, the Service Worker will begin to receive fetch events. These events give us the means to change responses to requests, or to cache them.

By checking if any GET request to a text or HTML resource has failed, we cover the “offline” case of our website, and respond with our offline page appropriately:

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate' ||
      (event.request.method === 'GET' &&
       event.request.headers.get('accept').includes('text/html'))) {
    event.respondWith(
      fetch(event.request).catch(error => {
        return caches.match(OFFLINE_URL);
      })
    );
  }
});

In case you are wondering, caches.match(OFFLINE_URL) - in the above code - returns a promise that will resolve into the cached resource.

Referencing the Service Worker in your website

A Service Worker is a JavaScript file, so we can just save all our code in sw.js and then reference this in our website, either in a script tag or in any existing script you may have, like so:

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js');
}

(Note: it doesn’t really matter where you put this script in your markup)

Here, we check if the browser supports Service Workers before we attempt to register it. While older browsers may not get to see anything different when a Service Worker is implemented, new browsers will enjoy the benefits (yay, progressive enhancement).

And that’s it for the code part.

Debugging your Service Worker

Debugging can be done in the “Application” tab of Chrome DevTools, under “Service Workers”. It shows us the Service Worker (that is active at this very moment) and allows you to debug the JavaScript of the Service Worker, force updates, and any other cool actions.

If you’re curious to see our implementation, you can check the “offline” checkbox in Chrome DevTools and refresh the page that you’re currently reading (alternatively, disconnect or disable your network).

custom offline page

All done: Recap

A quick recap on what we’ve seen so far.

So when a user first visits our website, our Service Worker will install itself and wait for activation, that is, if there is no Service Worker present yet.

At this point, requests to the network will start behaving like this:

  • The dormant Service Worker will “activate” and become the active Service Worker. It will then start intercepting all requests that go to the network (1).
  • If our user is online, the network calls will be successful (2), and the Service Worker will also fetch the offline.html page.
  • The Service Worker will put the offline.html page in cache (3) (but only if the offline.html page is not in the cache already and if it hasn’t recently been changed).
  • After installment of our Service Worker file, any time a user tries to get to any page and our Service Worker detects no network (2), it will instead retrieve the offline.html page from Cache (3) and show it to the user (4).

Service Workers

With very little time and effort, we can create a Service Worker that gives us a better user experience by serving our visitors a custom offline page whenever the network fails. This means that we can provide a performant experience that is consistent, even when network conditions are slow or when the network is down altogether.

This was just a simple, but useful example of what you can do with service workers.

While it may seem like a lot to take in at first, don’t let it stop you from experimenting with service workers today by implementing your very own offline page. Good luck!