Working with Service Workers

Service Workers are a tool given to JavaScript developers to write code handling caching, among other things. It allows you to write code that can control any outgoing requests.

I've written a straight-forward, simple Service Worker. It has a lot of comments to get you accustomed with the basic principles. Let's walk through the code and get our feet wet with Service Workers!

Table of Contents

  1. Adding a Service Worker to your site
  2. The Service Worker Lifecycle
    1. Installation
    2. Activation
  3. Hooking into HTTP requests
    1. Some caution to begin with
    2. Responding to the request
    3. Determining a caching strategy
      1. Prefer network, use cache as a fallback
      2. Prefer cache, fetch from network if unavailable
      3. Revalidating the resource in the background
  4. Quick performance improvements
    1. Support Navigation Preloads
    2. Race the network to handle flaky connections

Adding a Service Worker to your site

<script>
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/service-worker.js');
  }
</script>

That’s right, nothing more than one function call is needed to unlock the powerful features of Service Workers. To avoid breaking older browsers, the registration is best wrapped inside a feature detection conditional.

The Service Worker lifecycle

Installation

addEventListener('install', (e) => {
  e.waitUntil(self.skipWaiting());
});

The first block of code listens for the install event. This is an event that gets triggered when the browser has loaded the Service Worker and tries to install it. At this moment, any previously installed Service Workers are still doing their thing. Inside the install event handler, you can make the browser wait for certain things to happen by passing a Promise to the event.waitUntil method.

self.skipWaiting() tells the browser that it shouldn’t wait for previous Service Workers to properly detach and just move on with the installation already. This forces the Service Worker to become the active Service Worker, regardless of any other Service Workers.

Activation

addEventListener('activate', (e) => {
  e.waitUntil(self.clients.claim());
});

When the Service Worker is installed, it will get activated. This is the right moment to — you guessed it — activate the Service Worker.

self.clients.claim() allows the Service Worker to take over all existing clients. By default, the Service Worker would only become active on the next page load. Claiming all active clients connects the Service Worker to the active browser session.

Hooking into HTTP requests

Some caution to begin with

if (method !== 'GET' && method !== 'HEAD') {
  return;
}

With great power, comes great responsibility. To make sure the Service Worker doesn't accidentally mess with anything critical, it will short-circuit if the request is not a GET request.

Responding to the request

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith((async function() {
    const response = fetch(request);
    return response;
  })());
});

The Service Worker can send any arbitrary response to any request by passing a resolving Promise to the event with e.respondWith(). That's the hook provided to web developers to run custom code on the incoming request & determine what to do with it.

Determining a caching strategy

When a request comes in, there are a lot of different strategies to handling a request. You can work offline only, network only, serve from cache and use the network as a fallback, or vice versa.

The example uses two caching strategies. For HTML documents, we want to get the freshest content possible. The Service Worker will always try to fetch the latest data over the network. For all other assets, a cached version is sufficient. As an extra, the Service Worker will go ahead and try to fetch a new version in the background.

Prefer network, use cache as a fallback

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith((async function() {
    const response = fetch(request);

    return response.catch(async function() {
      const cached = await caches.match(request);
      return cached || response;
    });
  })());
});

The Service Worker tries to fetch() the request from the network. If the network request resolves, the Service Worker hands that resolved request back to the browser. If it doesn’t, however, it’ll find the request in the cache: await caches.match(request). The Service Worker will try and find a cached version of the request. When a cached response is found, the Service Worker will pass it to the browser. If the network request failed and there is nothing to fall back on in the cache, the Service Worker returns the failed request for the browser to handle.

Prefer cache, fetch from network if unavailable

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith(
    (async function() {
      const cached = await caches.match(request);
      return cached || fetch(request);
    })()
  );
});

When a request arrives in the Service Worker, before letting it go the network, the Service Worker looks for it in its cache. If a cached response is found, that response will be returned. If the cached version is undefined, the Service Worker lets the request go through to the network.

Revalidating the resource in the background

However, both implementations of the caching strategies discussed above will never add anything to the cache. We're doing that because for every single request, we want the Service Worker to revalidate the fetched resource in the background.

Keep in mind that the Service Worker is not the only cache that a browser has. For example, if your CSS file has a Cache-Control: immutable HTTP header and the Service Worker fetches a new version, that request will still hit the disk cache instead of downloading the resource.

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith(
    (async function() {
      const response = fetch(request);

      e.waitUntil(
        (async function() {
          const clone = (await response).clone();
          const cache = await caches.open(cacheName);
          await cache.put(request, clone);
        })()
      );

      // … Handle the request itself
    })()
  );
});

The response isn’t actually delayed by using e.waitUntil. Instead, it is a way to communicate to the browser that your Service Worker is still doing some work. You can pass Promises or async functions to e.waitUntil and the Service Worker will only be terminated once they are all resolved.

The Service Worker here waits for the network request to finish. When it does, it stores the response in the cache.

Note that a network response can be used only once. That's why the Service Worker clones the response before storing it in the cache: const clone = (await response).clone().

Quick performance improvements

The major use case for Service Workers is of course the impressive performance improvements it offers to your end users. In a lot of cases, we can assume that serving from disk will be a lot faster than having to go over the network for every request. That is only the tip of the iceberg: the flexibility of Service Workers gives you as a developer a lot to work with. In the example we’re discussing, there are two additional performance improvements.

Support Navigation Preloads

Service Workers live independently off browser sessions and are generally considered more persistent. However, with device resources being limited, it is never a given that a Service Worker will already be active to act on a HTTP request. It turned out that sometimes, booting a Service Worker so it can work with a HTTP request added noticable overhead to the response time.

A solution for that was Navigation Preloads. This is an unofficial, experimental technology that currently only works in Webkit-browsers. However, it’s rather straight-forward to add to a Service Worker and it can provide an undeniable speed improvement, so why not add it?

Adding support for Navigation Preloads has two parts to it. First, in the activate handler, the Service Worker has to enable Navigation Preload support.

addEventListener('activate', (e) => {
  e.waitUntil(
    (async function() {
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
    })()
  );
});

That's enough to inform the browser that you’re planning on using the preloaded response, which in turn will make the browser do navigation preloads. It’s important to actually consume the preloaded response if the Service Worker enables the support. Failing to do so would result in duplicate network requests.

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith(
    (async function() {
      const response = Promise.resolve(e.preloadResponse).then(
        (preloaded) => preloaded || fetch(request)
      );

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

If the browser supports Navigation Preloads, response will consume the preloaded response once it’s completely finished. In browsers that don’t support Navigation Preloads, e.preloadResponse will be undefined and a regular network request will be started.

Race the network to handle flaky connections

We’ve all been in situations where our internet connection was terribly slow or non-existing at all. The worst of all is the lie-fi connection when your device is connected to a network, but in reality, no data is coming down the wire.

A possible solution for dreadingly slow network responses could be to fall back on cached versions anyway. The Service Worker can add a timeout to the network request and decide to check the cache if the network is taking too long.

addEventListener('fetch', (e) => {
  const request = e.request;

  e.respondWith(
    (async function() {
      return Promise.race([
        fetch(request),
        new Promise((_, reject) => {
          setTimeout(() => reject(), 2000);
        })
      ]).catch(async function() {
        const cached = await caches.match(request);
        return cached || response;
      });
    })()
  );
});

The Service Worker starts two Promises at the same time. One is the actual network request, the other is a timer that will reject() after two seconds. Promise.race is a method that returns a Promise that will resolve or reject when the first Promise of the ones passed does so.

If the response returns within two seconds, the fresh response will be passed on to the browser. However, if the timer rejects first after two seconds, the Service Worker will look for a cached response. If nothing is found in the cache, the (still pending) network request is returned anyway for the browser to handle.

Using possibly stale content instead of a forever hanging network request is a small trade-off to make for an impactful user experience improvement.

Tying it all together

That’s about it! There is a lot more to cover about Service Workers, depending on what you are trying to do with it. I am convinced, however, that this is a good start for any simple site or blog that wants to leverage the offline capabilities and caching possibilities that come with Service Workers.

And again, find the full code on GitHub.

Back to Build Progressive