How to use service workers in javascript

tq-bit - Apr 15 '21 - - Dev Community

Progressive Webapps use service workers to make websites and webapps feel more like the native apps users know and love on their phones. This article will give you an introduction into the topic with some simple - to - follow code examples.

Technological purposes and limitations

Being a proxy between content on the internet and the user's client, service workers are addressing the issue of making browser-specific content available even when one's device is offline. Once registered, they are used for a variety of features, some of which are:

  • Client-side caching of static files and remote data
  • Serverside push - messages, e.g. with Node.js and web-push
  • (Periodic) background data synchronisation

Take devdocs.io. The site offers its full content within a Progressive Webapp (PWA) that will even be available if your computer or mobile phone is off the net, given you have installed it when visiting the website

When clicking on the + - sign, the PWA will be installed and grants you offline-access to devdocs.io

You should not mistake PWAs with desktop or native applications built with Electron.js or Nativescript though - they do only run on the browser's context and have no access to device specific APIs.

But even without using the full toolkit, service workers give you a high level of control over what gets cached, improving app speed and lowering server-side pressure. As of 2021, they are also supported in all major browsers, making them well considerable for production - ready apps.

Service worker constraints

When developing webapps using them, you have to consider that service workers

  • can only be used in a https - context for security reasons (note that for  development localhost is also considered a secure domain)
  • run on a separate browser thread, therefor have no direct DOM - access.
  • run completely asynchronous and rely a lot on promises. In case you need a refresh, I've got an article on promise basics here.

Project setup

You can of course follow freestyle, but I recommend you use the boilerplate from my Github repository - it includes placeholders for the functions that are introduced in this article, as well as some helper functions to create new elements on the DOM. If you just want to jump into the source code, there's also a 'done' branch.

https://github.com/tq-bit/service-worker-basic-demo/tree/main

The initial user interface looks like this:

an image that shows the initial application index.html file's content

The main.js - and the serviceworker.js file also include a bit of boilerplate, like logging and to create / query DOM elements.

Registration process, scope and state: Illustration

The following graphic from MDN perfectly sums up a service worker lifecycle. In the coming section, I'll use some code snippets below to illustrate how to hook up events to each of them.

an image from the mozilla development network docs that visualized the service worker lifecycle

Service worker Lifecycle by Mozilla Contributors is licensed under CC-BY-SA 2.5.

Registration process, scope and state:

Working code examples

Before you move ahead, let's take a moment and describe what we'd like to do next.

We will

  1. register a service worker script inside our application
  2. unregister the worker again and add a function to monitor if a worker is currently active in the user interface
  3. take a look at basic service worker features, such as initial file - as well as http-request caching  

Note that for each snippet below, there is a corresponding TODO - comment inside each file that serves as a placeholder. If you somehow get lost, try and use the Todo Tree VSCode plugin for quick navigation.

1: Register the service worker.

Before doing anything else, a service worker has to be downloaded and registered on the client-side. Imagine it as just another JS - file you would place inside the body of your index.html, just that it runs separated from the main thread. Like this, the lifecycle illustrated above will start and we have access to the Service-Worker's API.

Heads up: Many other articles also cover a few lines of code to check if service workers are supported by the browser. I've left this out on purpose, as most of the modern browsers have a built-in support as of today.

Add the following to your main.js file

// TODO: Add the registerWorker function here
const registerWorker = async () => {
 try {
  // Define the serviceworker and an optional options object.
  const worker = navigator.serviceWorker;
  const options = { scope: './' };

  // Register the worker and save the registeration in a variable. 
  const swRegisteration = await worker.register('serviceworker.js', options);

  // We will make use of this event later on to display if a worker is registered
  window.dispatchEvent(new Event('sw-toggle'));

  // Return the registeration object to the calling function
  return swRegisteration;
 } catch (e) {
  console.error(e);
 }
};
Enter fullscreen mode Exit fullscreen mode

Once you click the button Register Worker in your browser, the service worker is downloaded from the location you've given in the worker.register - method. It then proceeds to run through the lifecycle methods and, once that is done, remains idle until receiving an event-nudge from the main Javascript thread.

To confirm everything worked, check your browser's development tools under Application > Service Workers - as we can see, the registration process was successful and your worker is ready for action.

Heads up: While you're here, make sure to toggle the Update on reload checkbox - it makes sure that no data remains unintentionally cached.

an image that demonstrates how to register a worker and set the 'update on reload' option inside the development tools

2: Unregistering and monitoring

Now while one might just take the above code as given and use it as-is, I was curious to understand what exactly was going on with this registration object that is returned by the worker.register - method.

Turns out that, once downloaded and activated, a service worker registration is created inside the navigator.serviceWorker container and can be read out like this:

const swRegisteration = await worker.getRegistration();
Enter fullscreen mode Exit fullscreen mode

This means: If there are no active instances, the above variable declaration will resolve into undefined, which comes in handy and allows us to show possible registrations in our user interface.

Add the following to your main.js file:

// TODO: Add the unregisterWorker function here
const unregisterWorker = async () => {
 try {
  // Define the serviceworker
  const worker = navigator.serviceWorker;

  // Try to get a sw-registration
  const swRegisteration = await worker.getRegistration();

  // If there is one, call its unregister function
  if (swRegisteration) {
   swRegisteration.unregister();
   window.dispatchEvent(new Event('sw-toggle'));

   // If there's none, give a hint in the console
  } else {
   console.info('No active workers found');
  }
 } catch (e) {
  console.error(e);
 }
};
Enter fullscreen mode Exit fullscreen mode

To round things up, add the following to your main.js file for user feedback:

// TODO: Add checkWorkerActive function here
const checkWorkerActive = async () => {
 // Get registration object 
 const swRegisteration = await navigator.serviceWorker.getRegistration();

 // Query for the indicator DOM element and remove its classes
 const indicator = dqs('#worker-indicator');
 indicator.classList.remove('bg-danger', 'bg-success');

 // Change its content according to whether there's a registered worker or not
 if (swRegisteration && swRegisteration !== undefined) {
  indicator.innerText = 'You have an active service worker';
  indicator.classList.add('bg-success');
 } else {
  indicator.innerText = 'Service worker is not active';
  indicator.classList.add('bg-danger');
 }
};
Enter fullscreen mode Exit fullscreen mode

Finally, hook the method up to the sw-toggle event that is fired when registering and unregistering happens (therefor the window.dispatchEvent):

// TODO: Add the sw-toggle - event listener here
window.addEventListener('sw-toggle', () => {
  checkWorkerActive();
});
Enter fullscreen mode Exit fullscreen mode

Back to your app, the image below now shows an active service worker instance.

an image that shows the result of registering a service worker in the development tools

Once you click on Unregister, you can also monitor the change in your devtools

an image that shows the result of unregistering a service worker in the development tools

In case of manual registration / un-registration via the button, you might have to refresh your browser once to activate the service worker. In a productive scenario, this step should be taken by the website's / app's lifecycle methods

That wraps up how to handle registration and also what we want to do within our main.js file. Let's now take a look inside the serviceworker.js file.

3. Caching and offline-availability

Two basic functionalities of a service worker are making static files available for offline usage, as well as caching requests from a remote server. A core benefit to be taken away here is an improvement in user experience due to faster - or offline - page loading. To wrap this article up, let's find out how it works.

Note that, since a service worker will only work over https or from the localhost, you will need a development server to serve your files. If you're using VSCode, try the Live Server extension.

3.1 Service worker global 'this'

The global this behaves a bit differently inside a service worker - compared to the main.js - file. In a nutshell:

Inside the service worker file, the same provides us functions and properties such as self or caches. These we can utilize to enforce the service worker magic.

3.2 Caching strategies

Since the global service worker scope might compete with the version of your webapp, you have to make sure old caches get cleaned up properly before a new instance of your project is deployed. One method to do the same is to define an app version as well as a whitelist, based on which a new instance, before getting to work, can do some cleanup tasks (remember the visualization above? This happens in the active - phase). These two variables are already available in the serviceworker.js file, we'll use them in the upcoming snippets.

// appWhitelist indicates of which versions caches are meant to be kept
// If there is a gamebreaking change in static files or data delivery, 
// you should consider depracating old apps by removing their ids from here.
const appWhitelist = ['app_v1', 'app_v2', 'app_v3'];

// appActive indicates the currently active cache, or more specific the name
// of the cache used by the application. This variable should be synchronized
// with appWhitelist and fit the latest app version. 
const appActive = 'app_v1';

// appFiles holds the path to files that should be cached for offline usage 
const appFiles = ['./index.html', './main.css', './main.js'];
Enter fullscreen mode Exit fullscreen mode

In case you do not want to handle these strategies yourself, there are a few handy javascript libraries that can help you out, such as workbox-sw.

3.3 Caching static files

Having said and considered the above points, caching static files is as easy as adding the following snippets to your serviceworker.js file

// TODO: Add cacheAppFiles function here
const cacheAppFiles = async (appActive, appFiles) => {

  // Wait for the active cache version to open and add all files to it
  const cacheActive = await caches.open(appActive);
  cacheActive.addAll(appFiles);
};
Enter fullscreen mode Exit fullscreen mode

While we are at it, let's also add a function to get rid of old caches. Like this, we can make sure that only the current relevant cache is active and no old files will get in the way and cause inconsistencies.

const deleteOldCache = async (appWhitelist) => {

  // The caches.key property contains an array of cache names. In our case, 
  // their names would be app_v1, app_v2, etc. Each of them contains the 
  // associated cached files and data.
  const keys = await caches.keys();

  // In case the cache is not whitelisted, let's get rid of it
  keys.forEach((version) => {
    if (!appWhitelist.includes(version)) {
      caches.delete(version);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

Then, once a new service worker is installing, call this function. the event.waitUntil - method makes sure the above function resolves before moving ahead in the code. After installation, the files will then be cached and ready for offline usage.

self.addEventListener('install', (event) => {
  // Add the application files to the service worker cache
  event.waitUntil([cacheAppFiles(appActive, appFiles)]);
}); 

self.addEventListener('activate', (event) => {
  // Remove all old caches from the service worker
  event.waitUntil([deleteOldCache(appWhitelist)]);
});
Enter fullscreen mode Exit fullscreen mode

And that's about it - the defined files are now available within the service worker's cache.

3.4 Accessing cached content

The above makes sure our caching strategy is being enforced, but does not yet give us access to the files or data being stored. To gain access, our service worker has to listen to outgoing http-requests and then - based on our caching strategy - either return a cached response or fetch the data from the remote location.

Let us first add the necessary event listener. Add the following to your serviceworker.js - file

self.addEventListener('fetch', (event) => {
  // When receiving a fetch - request, intercept and respond accordingly
  event.respondWith(cacheRequest(appActive, event.request));
});
Enter fullscreen mode Exit fullscreen mode

As you see, cacheRequest takes in two arguments - the active version of the cache, as well as the outgoing request from the client to the server. It is meant to return a response that can be resolved as if there was no middleman involved. Therefor, before we write the code, let us first define what exactly is meant to happen.

  1. Check all active service worker caches (not just the currently active one, but all!) for an already cached response.
  2. If it exists, return it - no network communication happens and the http-request resolves. If it does not exist, move ahead.
  3. Check if the user is online (via navigator.onLine property)
  4. If user is online, execute the fetch-request. When it resolves, clone the raw response and put it into the currently active service worker cache (not all, just the currently active one!). Also, returns response to the calling function
  5. If user is offline and no cached content is available, log an error to the console.

At this point, I'd like to state that a carefully chosen caching-strategy in step 3.1 is key to properly handle these interceptions.

In the described case, a static file's version that has been cached in app_v1 will still be fetched in app_v2 if the app_v1 still remains in the appWhitelist array. This will cause problems if the initial file is outdated.

Now, to wrap caching up, add the following to your serviceworker.js - file

const cacheRequest = async (appActive, request) => {
  const online = navigator.onLine;

  // 1. Check if a cached response matches the outgoing request
  const cachedResponse = await caches.match(request);

  // 2. If response has been cached before, return it
  if (cachedResponse) {
    return cachedResponse;

    // 3. Check if user is online
  } else if (online) {

    // 4. If response is not in cache, get it from network and store in cache
    const response = await fetch(request);
    const resClone = response.clone();
    const cache = await caches.open(appActive);
    cache.put(request, resClone);

    // Return the response to the client
    return response;
  } else {

    // 5. If none of the above worked, log an error
    console.error('No cached data and no network connection recognized');
  }
};
Enter fullscreen mode Exit fullscreen mode

3.5 Final result and outlook to other features

It was a tough ride, but we've finally arrived at the point we can put everything together. What we can do now is:

  • Cache static files and remote server responses
  • Access not only one, but several caches at once
  • Integrate a simple caching strategy that keeps our caches lean and clean

Don't take my word for it though - try it out yourself. Below, I'll link you the final Github branch so even if you didn't follow every single step, you can get your hands dirty and try an offline-first approach. If you'd just like to take a glimpse into the functionality of this article's proof of concept, I've also added some screenshots for that under 4. Working samples.

https://github.com/tq-bit/service-worker-basic-demo/tree/done

So what are you waiting for? Clone down that repos and start coding.


4. Working samples

4.1 Lifecycle and exercising caching strategies

Assume you just deployed your service worker app or release a new app (and therefor a new cache) - version, your service worker will do the necessary setup during installation:

an image that shows how a service worker is being registered. it also shows console logs, visualizing how functions are being called while the service worker is installed and activated

A new service worker will always clean up old versions that are not whitelisted and make sure the static files are available before the first fetch request. Note how it conveniently caches the bootstrap css I'm using for the styling.

4.2 Service worker at work - online

Once registered, try and fetch the test data once. You'll notice they get cached and retrieved in case a subsequent request matches a cached response. While the static files were available right away, the dynamic data from jsonplaceholder were not. After they have been saved once, however, and the appVersion remains part of the appWhitelist, the service worker will deliver the data from the cache instead of getting it from the remote server.

the image shows how a website with a service worker retrieves content while the client is online

Static content is available straight away, as it's been registered while installing the service worker. Remote data have to be fetched once on demand.

4.3 Service worker at work - offline

The same thing now also works offline. Try to tick the 'Offline' checkbox in your devtools and hit 'Fetch test data'

the image shows how a website with a service worker retrieves content even when the client is offline

The content is now always delivered from the cache.

This post was originally published at https://blog.q-bit.me/an-introduction-to-the-javascript-service-worker-api/
Thank you for reading. If you enjoyed this article, let's stay in touch on Twitter 🐤 @qbitme

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .