Getting started with Service Workers

The Service Worker API is a recent web technology that brings features in the browsers that before could only be found in native applications, such as rich offline experiences and push notifications. They can also help to improve the online experience by reducing page loading times.

In this article, we will be discussing the core principles behind the API and provide you with enough information to get started. We will be outlining the lifecycle of a Service Worker, together with some common caching strategies. Finally, we will show a basic Service Worker implementation and supply some practical tips that we learnt here at Toaster while experimenting with the technology.

What is a Service Worker?

A Service Worker is a script run in the background by the browser. It acts as a programmable network proxy, sitting between the Web Application on one side, and the browser cache and the network on the other.

Note: At the time of writing, Service Workers are supported by Chrome, Firefox and Opera (and soon in Edge), while they are still under consideration in Safari. This technology should be seen as a progressive enhancement: browsers that support it are going to offer to their users a more advanced experience, while the remaining browsers are just going to serve the site as they would have done before. This also means that developers should not ignore HTTPS caching and its best practices.

The Service Worker API is quite complex by design, as it aims to give developers granular control over how the requests are handled and cached. Because of its ability to hijack connections, for security reasons a Service Worker can only be registered on pages served over HTTPS. A Service Worker is a Web Worker, and therefore it can't access the DOM, is run on a separate thread, and has a lifecycle that is separate from the web page.

Service Worker Lifecycle

  1. At the beginning of the registration process, the Service Worker script is downloaded, parsed, and will begin the install process in the background. This is usually a good moment to pre-cache offline assets. This step may fail in any of the assets can't be cached.

  2. Once the installation completes successfully, the Service Worker needs to wait for all the clients using other previous workers to be closed. This phase can be avoided by using the skipWaiting() method, which will allow the Service Worker to activate immediately.

  3. Once there are no clients controlled by other workers, the Service Worker can activate. During the activation, it can finish its setup and clean previous workers' related resources.

  4. The Service Worker is now activated and in control of the client. It can handle functional events, like fetch, push and sync, and can communicate with the client through message events.

  5. The Service Worker can become redundant if the installation step has failed, or if it is being replaced by a new Service Worker.
    Moreover, a Service Worker can be terminated at any time by the user agent, in case it becomes inactive or behaves unexpectedly.

Moreover, a Service Worker can be terminated at any time by the user agent, in case it becomes inactive or behaves unexpectedly.

What to cache and when

As already mentioned, the Service Worker API gives the developer a high degree of control. This means that we can apply different patterns and strategies, depending on what to cache, when to cache it, and how to serve it back to the client. For a detailed list of the different patterns, you can have a look at "The offline cookbook" written by Jake Archibald (co-author of the Service Worker spec) and at the Service Worker Cookbook by Mozilla.

Common patterns include:

  • Pre-cache static dependencies at install time (e.g. HTML, CSS, JS, fonts, favicons, critical images..), runtime-cache other non-essential resources.
  • Cache specific content on user interaction (e.g. videos, articles…)
  • When serving the cached resources back to the client, use a network-first (falling back to cache) or fastest strategy for resources that update frequently (e.g. API requests), otherwise opt for a cache-first strategy.
  • Do not cache fallback-browsers assets

Setting up a Service Worker

Here's some sample code for registering a Service Worker:

if ('serviceWorker' in navigator) {
  // Delay registration until after the page has loaded.
  window.addEventListener('load', () => {
    // Your service-worker.js *must* be located at the top-level directory relative to your site.
    navigator.serviceWorker.register('service-worker.js').then(reg => {
      reg.onupdatefound = () => {
        reg.installing.onstatechange = () => {
          switch (installingWorker.state) {
            case 'installed':
              if (navigator.serviceWorker.controller) {
                // TODO: notify user: "New or updated content is available."
              } else {
                // TODO: notify user: "Content is now available offline."
              }
              break;

            case 'redundant':
              console.error('The installing service worker became redundant.');
              break;
          }
        };
      };
    }).catch(e => {
      console.error('Error during service worker registration:', e);
    });
  });
}

And here a basic Service Worker that pre-caches resources on install, deletes old caches on activate, and responds to fetch events with a Cache-first strategy.

const SW_VERSION = 1;
const CACHE_NAME = `my-cache-v${SW_VERSION}`;
const CACHE_WHITELIST = [CACHE_NAME, 'videos-cache'];

const PRECACHED_FILES = ['/', 'index.html', 'app.js', 'styles.css']

// Install: open cache and add all dependencies.
// Optional: skip waiting and activate immediately.
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHED_FILES))
      .then(() => self.skipWaiting())
  );
});

// Activate: clean previous caches.
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(cacheNames.map(cacheName => {
        if (CACHE_WHITELIST.indexOf(cacheName) = -1) {
          return caches.delete(cacheName);
        }
      }));
    })
  );
});

// Service Worker is active, and can handle fetch requests from the client.
// Example 'Cache First' strategy: serve from the cache of possible,
// otherwise hit the server (and runtime-cache that response for the next time).
self.addEventListener('fetch', event => {
  if (event.request.method != 'GET') return;

  // TODO: check `event.request.url` and decide what files to runtime-cache.
  event.respondWith(
    caches.match(event.request).then(response => {
      // Cache hit.
      if (response) return response;

      // Clone the request stream, as one stream is consumed by cache and
      // the other one by fetch.
      var fetchRequest = event.request.clone();
      return fetch(fetchRequest).then(response => {
        // Check if the response is valid.
        if (!response || response.status != 200 ||
            response.type != 'basic') {
          return response;
        }

        // Clone the response stream, as one stream is consumed by cache and
        // the other one by the browser.
        var responseToCache = response.clone();
        caches.open(CACHE_NAME).then(cache => {
          cache.put(event.request, responseToCache);
        });

        return response;
      });
    })
  );
});

Although writing a Service Worker from scratch is a very good way to get to know how the API works, it can be a long and error-prone process. As an alternative, there are libraries designed to make writing a Service Worker an easier task, just by specifying a small set of options. Unless your Service Worker has a very specific behaviour, you should definitely consider adding them to your toolchain. Here at Toaster, we use Google's Workbox.

Avoiding common mistakes during implementation

Service Workers can be hard to master; while they enable tailored behaviours for each site, their low-level spec inevitably increases the possibility of introducing unwanted bugs. Here's a list of practical tips to keep in mind.

  • The Service Worker specification is still a Working Draft, meaning that it isn't fully stable yet. Different parts of the spec have been implemented in different browsers at different times.
  • Do not cache the Service Worker script (i.e. Cache-control: no-cache, max-age=0), unless you know what you're doing. As of today, browsers tend to cache a Service Worker script for up to 24 hours.
  • Testing a Service Worker can be a difficult task; it's hard to write tests that reflect the real behaviour of the browsers, and are fast and reliable at the same time.
  • Wait for the load event before registering a Service Worker—don't compete with the page load for network and CPU resources.
  • Cache smarter: consider caching never-changing, static assets in a separate cache from versioned assets, so that they don't need to be downloaded again after a Service Worker update.
  • Do not call skipWaiting() after a major change in your Service Worker—prompt the user with a "Reload" message instead.
  • Always check the response.ok and response.type values before caching its contents.
  • A Service Worker can behave unexpectedly if the site sits behind authentication—remember to check the response.status and to set the credentials: 'include' option when fetching a resource.
  • Have a way of forcing the uninstallation of an existing Service Worker..
if (forceSwUnintall) {
  navigator.serviceWorker.getRegistrations().then((registrations) => {
    for (let r of registrations) { r.unregister(); }
  });
}

..or have a no-op Service Worker script ready to take immediate control:

// A simple, no-op service worker that takes immediate control.

self.addEventListener('install', () => {
  // Skip over the "waiting" lifecycle state, to ensure that our
  // new service worker is activated immediately, even if there's
  // another tab open controlled by our older service worker code.
  self.skipWaiting();
});

/*
self.addEventListener('activate', () => {
  // Optional: Get a list of all the current open windows/tabs under
  // our service worker's control, and force them to reload.
  // This can "unbreak" any open windows/tabs as soon as the new
  // service worker activates, rather than users having to manually reload.
  self.clients.matchAll({type: 'window'}).then(windowClients => {
    windowClients.forEach(windowClient => {
      windowClient.navigate(windowClient.url);
    });
  });
});
*/

Start today!

At Toaster we've been steadily integrating Service Workers into our projects for more than a year. YouTube Guru and Making & Science are some of the web builds we've produced using this technology and it will continue to be a part of our development process going forward.

In this article we explained what we believe are the most important principles behind this technology, such as its lifecycle and the common caching strategies to adopt, together with some more practical tips.

The incredible potential that Service Workers unlock, together with their “progressive enhancement” nature and the increasing availability of resources and tools, means that this technology can be safely adopted on any project today.

When supported, it can enable powerful features; when not supported, the site will fallback gracefully.

Just be careful: Service Workers are extremely powerful, and with great power comes great responsibility.

If you would like to learn more about PWAs check out our other articles on the topic including An introduction to Progressive Web Apps and Four key PWA technologies you can start using right now .