Diego IrigarayAug 14, 2020

Understanding Service Workers

If you are a web developer you may have already come across Service Workers, they have been gaining popularity for the last few years and are now part of many popular web applications.

Their adoption is based on the fact that they offer multiple ways of improving the overall user experience, however, not knowing what they actually do may lead to some unexpected behaviors when using them on the real world.

In this post I will cover the basics behind Service Workers, explaining what they are and how they work, hopefully bringing a better understanding of what happens in the background when you visit one of these websites.

The basics

Service Workers are basically Javascript code in which developers have control over the requests made by their applications, allowing them among other things to apply caching techniques to both improve the performance and provide offline capabilities.

The developer gets to decide how and when to store data in the browser’s storage, like images, HTML, JS, and CSS files. Then, when the user tries to access your page you can choose to serve these resources directly from the browser’s cache, which is usually much faster than getting them from the server.

Having these resources stored locally also allows you to use them even when the user has no internet connection, making it possible to provide a better offline experience as well.

Service Workers also include a couple of other features like push notifications and background sync, but they are not as widely supported yet so I will ignore them on this post, focusing on the caching aspect of this technology.

Details

On the more technical side, Service Workers are a special type of Web Workers from where they inherit many of their key characteristics:

  • They take the form of a regular Javascript file. This file must then be registered, in this case as the Service Worker of our app.
  • Service Workers run on their own thread, separate from the main thread of our application.
  • They don’t have direct access to the DOM, and communication with the main thread must be done via messages using the postMessage interface.
  • Service Workers have access to numerous browser’s APIs. Some of the most used ones are the Cache and IndexDB APIs used for local storage and the Fetch API used for fetching resources from the server.

What makes Service Workers special is that they also act as network proxies. They are able to intercept all kinds of requests made by the application, and as I mentioned earlier, the developer has total control over what to do with these requests, opening the door for some custom caching strategies.

Given that this proxy capability can cause some serious security risks, the use of Service Workers is limited to applications served using HTTPS, being localhost the exception to this rule.

Another particularity is that Service Workers are event-driven and designed to work in a fully asynchronous manner, making extensive use of Promises. As we will see in the following section, all their logic is tied to the few events they receive, which apart from sync and push (excluded here for lack of support) are:

  • install and activate, which are fired during the initialization of the Service Worker
  • fetch, fired for every request made by the app
  • message, fired when another thread sends a message to the Service Worker

Next, we will see the steps needed to use a Service Worker on a given application and I will explain how these previous events come into play, leaving the message event out since it’s the least relevant of the four, but you can read about it here.

Lifecycle

Probably the most confusing aspect of Service Workers it’s their lifecycle, what are their different states and how they transition from one to another before being able to intercept requests.

This section covers the phases a Service Worker goes through in its life, along with some particularities of each one of them.

Registration

The first step when using a Service Worker (more on how a Service Worker looks later) is to register it on your application. This can be done anywhere on your Javascript code, you only need to specify where your Service Worker is and optionally the scope for it, which by default is assumed to be its current path within your origin.

This scope determines which requests will be routed through the Service Worker, for example, if the scope is just the origin all requests made by our app will be passed to the Service Worker, however, if it’s scope is a subpath inside the origin, it will only intercept requests made from within or directed to that subpath.

When you register a file as your Service Worker, the browser will try to get it from the server returning an error if for example, it can’t find the specified file, or if the scope provided isn’t valid. When successful, it will return a registration object which allows you to check the used scope, detect when new Service Workers are installed, and more.

if ('serviceWorker' in navigator) {
   navigator.serviceWorker.register('/sw.js').then((reg) => {
       console.log('SW registered with scope: ', reg.scope);
   }).catch((err) => {
       console.log('SW failed to register: ', err);
   })
}

The code above is a simple example of how to register a Service Worker named sw.js, located in the root directory of our app. It checks for browser support (nowadays all major browsers have at least basic support for Service Workers), registers the sw.js file, and then logs the result of the registration.

Installation

Once the Service Worker is registered it moves to the installation phase. A Service Worker is only installed if it’s considered new, that is, if it is the first Service Worker registered for the given scope or if it is different (bytewise) to the existing one.

During the installation, the install event is fired and we can react to it in the Service Worker. This is the first event received by any new Service Worker, so it’s usually used to fetch and store the essential resources of the app, which can then be used to provide offline use or to speed up the next page load.

Something to keep in mind is that during this phase there might still be another Service Worker functioning and controlling the app, so you shouldn’t perform actions that may disrupt it’s correct behavior, like for example deleting old resources.

Errors during the install event handling will result in the new Service Worker being discarded. The installation will then be retried following the next call to the register method.

const prefetch_urls = [
   "/",
   "/offline.html",
   "/main.css",
   "/app.js",
]
 
self.addEventListener("install", (event) => {
   event.waitUntil(
       caches.open('cache-v1').then((cache) => {
           return cache.addAll(prefetch_urls);
       })
   );
});

This sample code would be placed inside the sw.js file and it shows a typical install handler where the Service Worker simply fetches a few resources from the server and stores them in the browser using the Cache API, inside a cache named “cache-v1”. In this case, if any of the requested files fails to download the whole installation would fail and thus the Service Worker would be discarded.

Activation

The final step for a Service Worker to become functional is the activation. After being successfully installed, new Service Workers will remain in a waiting state until they get the chance to be activated. By default, activation is done by the browser once there are no longer any tabs running a previous version of the Service Worker.

This happens immediately after installation for the first registered Service Worker, but in the case of updates (when there is a previous Service Worker activated), the user needs to close all the application tabs for the activation to happen. Something to notice is that refreshing the page isn’t enough for a Service Worker to be activated, so new changes won’t apply until all tabs are closed and opened again.

One other way of forcing the activation is via the skipWaiting method, that as the name implies will force the Service Worker to skip the waiting state and be activated immediately. This call can be done anywhere in the Service Worker, the most common places being during the installation or as a response to a received message. This way the user can get updates without having to close the web app.

During the activation, the activate event will be fired. This is the moment when the previous Service Worker stops being used and the new one takes control, so it’s a good moment to delete resources that will no longer be used.

self.addEventListener("activate", (event) => {
   event.waitUntil(
       caches.keys().then((keyList) => {
           return Promise.all(keyList
               .filter(key => key !== 'cache-new')
               .map(key => caches.delete(key)))
       })
   );
});

This code would also be placed inside the sw.js file. In this example, I used the activate event to delete any old cache whose name is not “cache-new” (assuming the new Service Worker will only use this new cache).

Once activated the new Service Worker will start receiving functional events, particularly the fetch event presented next.

Fetch

Finally, we have what’s probably the most important event available to Service Workers, the fetch event. Handling this event gives the developer the opportunity to intercept requests and decide which strategies to use in each case, like either looking for a response in the cache or trying to get the response from the server first, and many others.

self.addEventListener("fetch", (event) => {
   event.respondWith(
       caches.match(event.request).then((resp) => {
           return resp || fetch(event.request);
       }).catch(() => {
           if (event.request.mode === 'navigate') {
               return caches.match('/offline.html');
           }
       })
   );
});

In this simple example, I use a cache-first strategy responding from the cache when possible and requesting from the network otherwise. Additionally, I added a fallback for when a navigation request can’t be served either from cache or from the network, responding with a generic “offline” page which was previously stored during the installation.

Conclusion

This post barely covers the basics behind how Service Workers work, there are many more ways they can be used, and many more things they can do. Nonetheless, the principles presented here apply to every Service Worker, no matter how complex, and the presented handlers, even though immensely simplified, also represent some very common approaches used in real-world applications.

I would encourage anyone interested in Service Workers to take a look at some examples generated with tools like workbox and try to identify some of the previously presented concepts.

And lastly, another great way to gain some insights on the subject is inside chrome’s developer tools. The Application tab has a bunch of really useful features that allow you to inspect and interact with your Service Workers, and in conjunction with the network data you can analyze how your app is being served at any given moment.