Offline

A guide to Service Workers - pitfalls and best practices

Service worker is one of the most useful features that came to modern browsers. I've always enjoyed the features they can bring into web apps such as caching assets, and enabling offline user experiences. Implementation can be quite tricky though and prone to a lot of issues that could easily slip into production if not tested properly.

In this post, I'll be discussing my set of "tips from the trenches" that I've documented along the way while building out and auditing several service worker implementations.

Make sure to set the right scope for your service worker

One of the early mistakes that one might make, is to include the service worker script into some sub-directory without specifying a scope. In a typical web app, adding JS related files into a specific sub-directory sounds pretty natural. For Service Worker files, this can be problematic by default since scope of the service worker is defined by its location relative to the web root of the web app.

This means, if you put your script in a /assets/js directory its scope is limited to the /assets/js. In that case, the service worker can only handle or intercept requests through examplesite.com/assets/js/, and completely ignore other requests such as those going through examplesite.com or examplesite.com/news/ for example.

If you want the service worker to handle any request coming through any part of the site, you have have two options:

  • Place the service worker file on the web root directory, which would set its scope to cover the whole site by default.
  • Explicitly define the service worker's scope when registering it:
  navigator.serviceWorker.register('/assets/js/sw.js', {
    scope: '/'
  });

Delay registering a service worker until after a web app's load event fire

When a page is loading, all resources such as bandwidth and CPU time, should be prioritized to serving the minimum set of critical resources needed to display an interactive page to the user. This is even more critical in a scenario such as a first time mobile browser accessing a page, not running over a flaky 3G network. You wouldn't want the service worker to be competing on resources while the page is being rendered.

It's recommended to always delay registering a service worker until the web app has loaded properly. A general rule of thumb would be register it on the window load event, example:

  if('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('sw.js');
    });
  }

The above applies to many cases, but the right time to start the registration would highly depend on the web app and how it works. If you're using a framework that needs additional setup after the page has loaded, you might want to check for framework specific events that signal that the work is done.

For more information on optimizing resource usage on page load, I would suggest checking out a user's first journey.

Don't worry about a service worker caching itself

If a service worker is set to intercept and cache all requests, one might worry that an older service worker could end up serving a cached version of itself rather serving and registering a new one.

It turns out, a service worker's fetch event handler is not triggered when the page requests a service worker script to register or update. For example, when you call navigator.serviceWorker.register('sw.js) the request for sw.js isn't intercepted by any service worker's fetch event handler.

For reference, you might want to look at the specification of service worker's update algorithm.

A service worker's cache is accessible from the main thread

In many cases, you might want to manage some aspects related to the app shell or content cache from within the main thread based on certain user interactions.

The good news is that the service worker's cache is accessible from the main thread via window.caches just like you'd use self.caches within the service worker's code.

This snippet, shows an example of that.

Use a library for messaging

As you might have noticed, the service worker can't access the main thread and its related resources, such as DOM and Window object.

To communicate with the main thread, the service worker uses postMessage to send data and receive data.

You might want to reduce the overall complexity of postMessage by using a wrapper library for that such as Swivel

Clone the fetch response stream when it needs to be consumed more than once

As an optimization for efficient memory usage, fetch gets the request/response body as a stream that doesn't get buffered into memory and can be consumed only once. This could get complicated when you're trying to read the response stream multiple times, such as pushing the result into the cache and returning the response back to the client.

The solution in such case would be to clone the response stream. Below is an example of a cache first strategy using that approach. Notice how on line 6 the response object is cloned before adding it into the cache while the original response stream gets returned to the client.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE).then((cache) => {
      return cache.match(event.request).then((response) => {
        return response || fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

I highly recommend checking out the article on what happens when you read a response? explaining this concept in detail.

Avoid adding a "pass-through" fetch handler

If you're registering a service worker yet have no need to intercept and cache any request, you might feel you should define "pass-through" fetch event listener such as:

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request));
});

While this pass-through won't affect the behavior of the web app, it can introduce a performance overhead due to several reasons.

  1. It adds a latency hit for every single network request made, mainly due to calling respondWith and fetch unnecessarily.

  2. If the service worker is not already running, there is usually an overhead in starting it up. Many browsers optimize on that by not starting up the service worker on network requests unless a fetch handler is defined. Registering a fetch handler that doesn't do anything leads to consuming resources on the user's device unnecessarily.

Avoid caching bad responses by properly handling them in fetch

A fetch promise always returns a response object for the given request, which contains the status code of the response. This is true for all responses including those containing error status codes (such as 4xx and 5xx).

It's important to keep that in mind when implementing caching strategies so that you don't end up caching bad responses.

While the below cache-first strategy example would work, it's missing a very important detail around handling the status of fresh responses from the server. It will be caching pretty much any reply with any status!

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE).then((cache) => {
      return cache.match(event.request).then((response) => {
        return response || fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        })
      });
    })
  );
});

Always keep in mind that HTTP responses with errors (such as 4xx and 5xx) won't be throwing any exception. They will need to be handled in the .then clause by checking the status of the reply. An improvement that can be done over the example above would be to check the response status and only cache successful responses.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE).then((cache) => {
      return cache.match(event.request).then((response) => {
        return response || fetch(event.request).then((response) => {
          if (response.status === 200) {
            cache.put(event.request, response.clone());
          }

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

The only case where an exception is thrown by fetch is when the request completely fails due to network failure, such as being offline. You could take such an opportunity to handle the exception and serve a custom reply on the resource in that case. Example below:

const offlineHTML = `<h1>You're offline!</h1>`;

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE).then((cache) => {
      return cache.match(event.request).then((response) => {
        return response || fetch(event.request).then((response) => {
          if (response.status === 200) {
            cache.put(event.request, response.clone());
          }

          return response;
        }).catch(() => {
          return new Response(offlineHTML, {
            headers: {
              'Content-Type': 'text/html;charset=utf-8'
            }
          });
        });
      });
    })
  );
});

Note: 2 crucial details are intentionally missing from the above examples to keep things simple for illustration purposes:

  1. Route and file type checking on the requested resource, which I'm assuming are HTML documents
  2. Checking and setting cache expiry

Hence, I wouldn't advise copy/pasting the above code into production as is.

Setting web server cache-control policy the for service worker file

This issue is becoming less of a concern over time as recent major browser versions are shipping changes, implementing an update on the default service worker file caching behavior, which now gets the browser to ignore the HTTP cache directive when checking for updates on the service worker script.

For supporting older browser version, you might still want to make sure that the request to sw.js isn't cached by the browser. This can be simply done by setting Cache-control: no-cache and max-age: 0 on the service worker file in your web server. In nginx, the configuration can looks something like this:

location ~* (sw\.js)$ {
    add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
    expires off;
    proxy_no_cache 1;
}

You can later test out the service worker's cache-control setting via: curl -X GET -I http://yourwebsitehere.xyz/sw.js

Evaluate caching strategy examples and never copy paste them directly

Looking at examples online is great, but always make sure to learn something from them over copy pasting the example into your code (This is recommended for the code shared here too).

While this rule applies to any piece of code you're writing, it can be particularly dangerous with caching strategies, even if some might seem production ready.

This is because every implementation will highly depend on the nature of the web app, how it behaves and the business logic involved. Here is a small checklist I keep in mind when reviewing a service worker code example:

  • Check for routing and file type patterns, do they make sense?
  • How does it implement cache expiry logic? Does that apply to my requirements?
  • Cache TTL, is it uniform for all caches or specific to certain file types/routes ?
  • Which caching strategies does it apply? For which resource types / routes ?

I would go over the above criteria and evaluate how each one of those would apply/make sense within the specific use case and modify accordingly.

You might not need to build a service worker from scratch

Finally, when going for production, you might not really need to build out the service worker from scratch. Libraries like Workbox can provide several helper functions and wrappers out of the box for complex implementations which could save you several hours of troubleshooting, testing and headache.

If you're just starting out, I would still recommend playing around with building a couple of vanilla implementations in order to get a grip at understanding how service workers work before jumping into using a library though.

Did I miss anything?

I would love to hear more about your tips on service worker implementation that I might have missed in the comments below. Would also appreciate any other feedback you might have!

Enjoyed this post? Help me spread the word and let me know your feedback!

Subscribe via