Lessons learned on offline capabilities with service workers using Workbox - The sequel

Lessons learned on offline capabilities with service workers using Workbox - The sequel

Posted on
Last update on

Target audience

This is a follow-up on my previous article about lessons learned on offline capabilities using Workbox. This article highlights some more findings and explores better/other solutions for the problems described earlier.

What will we discuss in this post?

  1. Better detection for a first-time install of a service worker
  2. Updating the application while it's in use

All the example code used in this post, just as this complete website, is available on Github here as an example project for you to test and try-out.

Better detection for a first-time install of a service worker

In my previous post about service workers and Workbox I used a somewhat shady technique with a refresh to activate the service worker functionality on the first load of the application. This is completely unnecessary and the same outcome can be achieved using other techniques.

If we consider the code to register the service worker from the previous post, the only thing we have to change is send an event to the service worker to claim the clients, instead of reloading the page. This is demonstrated on line 19 and 25 in the following code. The logic to detect a first service worker remained unchanged.

service-worker-registration.ts

// Check that service workers are available
if ('serviceWorker' in navigator) {
  // Use the window load event to keep the page load performant
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js').then(registration => {
      if (navigator.serviceWorker.controller) {
        // let the application know our service worker is ready
        window['serviceWorkerReady'] = true;
        window.dispatchEvent(new CustomEvent('service-worker-ready'));
      }

      // A wild service worker has appeared in reg.installing and maybe in waiting!
      const newWorker = registration.installing;
      const waitingWoker = registration.waiting;

      if (newWorker) {
        if (newWorker.state === 'activated' && !waitingWoker) {
          // - no more window.location.reload();
          newWorker.postMessage({ type: 'CLIENTS_CLAIM' });
        }
        newWorker.addEventListener('statechange', () => {
          // newWorker.state has changed
          if (newWorker.state === 'activated' && !waitingWoker) {
            // - no more window.location.reload();
            newWorker.postMessage({ type: 'CLIENTS_CLAIM' });
          }
        });
      }
    })
    .catch(err => {
      console.log('service worker could not be registered', err);
    });
  });
}

Please don't use the above code in your application, as it's a bad implementation! A better implementation using Workbox is discussed below.

For this to work we need to make our service worker react to messages and claim the clients for this installation of the service worker. We do this by registering an event listener on the message event:

service-worker.js

// ... other sw functionality

self.addEventListener('message', event => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
  if (event.data && event.data.type === 'CLIENTS_CLAIM') {
    self.clients.claim();
  }
});

If a service worker is installed, but not controlling the page yet, an event that matches the type CLIENTS_CLAIM will claim the clients and activate the service worker for those clients.

Using Workbox Window to solve the same problem

With Workbox Window we can listen for the activated event on the service worker registration. This events holds a property isUpdate that indicates if a previous version was controlling the page. If not, we can safely assume this is a first time install.

service-worker-registration-workbox-window.ts

import { Workbox } from 'workbox-window';

const wb = new Workbox('/service-worker.js', {});

wb.addEventListener('activated', async event => {
  // `event.isUpdate` will be true if another version of the service
  // worker was controlling the page when this version was registered.
  if (!event.isUpdate) {
    // If your service worker is configured to precache assets, those
    // assets should all be available now.
    // So send a message telling the service worker to claim the clients
    // This is the first install, so the functionality of the app
    // should meet the functionality of the service worker!
    wb.messageSW({ type: 'CLIENTS_CLAIM' });
  }
});

wb.register();

Similar to our previous example, without Workbox Window, our service worker needs to be listening to messages and act accordingly on the message of type CLIENTS_CLAIM. wb.messageSW is a Workbox wrapper function that provides a message and reply mechanism using a MessageChannel but basically just sends a message to the service worker using postMessage.

Visual representation

The animated GIF shown above highlights the steps our code takes in registering a service worker for the first time. Let's go over all the steps shown in the animation:

  1. Our application creates a new Workbox instance that registers the service worker.
  2. The service worker installs and proceeds trough the waiting process to the activated process. At this point the service worker is not yet controlling the application (or client).
  3. Through the workbox event system we receive the activated event. We check based on this event if the activation is an update or not. In this case it's not.
  4. The application logic sends an event trough the messasging system of Workbox, using postMessage, an event CLIENTS_CLAIM instructing the service worker to take control over the active clients.
  5. The service worker receives this event and acts accordingly, executing clients.claim().

Updating the application while it's in use

With PWA's installed on the homescreen and opened as native-like applications it's possible that those applications are running for a long time. Updating a new version of the application and installing a new service worker could potentially be extended or postponed for a long time, until the user closes and reopens the application, or refreshes the application.

It's even possible to disable the refresh by pulling down the application completely. This could be done if the functionality requires it. In case of for example a full screen map, pinching, zooming and other gestures could lead to an unexpected refresh.

In those cases the only way of updating seems to be closing and reopening the application. Fortunately it's not. There are several ways of doing an in-application update. By means of user-intent, when the application becomes visible again or by setting up an interval. Let's explore these 3 options.

Update application by user-intent

The simplest way of asking for an update to the application is by letting the user click a button. So we are gonna take this example to show how to update the application while running it. The other options and examples are just defining a different approach or intent to update.

To be able to update our application while running it we need to make sure that we keep track of the original service worker registration. Checking for a new version and performing an update is then as simple as just calling swRegistration.update(). Please consider the following ServiceWorkerService:

app-update-user-intent.ts

import { Injectable } from '@angular/core';
import { Workbox } from 'workbox-window';

@Injectable({
  providedIn: 'root',
})
export class ServiceWorkerService {

  private swRegistration: ServiceWorkerRegistration;

  constructor() {
    this.registerServiceWorker();
  }

  public async checkForUpdate() {
    try {
      console.log('updating sw');
      return await this.swRegistration.update();
    } catch (err) {
      console.log('sw.js could not be updated', err);
    }
  }

  private async registerServiceWorker() {
    const wb = new Workbox('/sw.js', {});

    try {
      this.swRegistration = await wb.register();
    } catch (e) {
      console.log('error registering service worker', e);
    }
  }

}

This Angular example service, which can be easily ported to any framework, registers the service worker as soon as the service gets instantiated. The register method call on the Workbox instance gives us a native ServiceWorkerRegistration that we save in the serviceWorkerRegistration property.

Now any button in the application can be coupled to check for an update of the application by calling the checkForUpdate method. This method uses the previously saved swRegistration and calls update on it.

The update method tries to update the service worker by fetching the registration's URL. In case there is a byte-by-byte difference to the current service worker, the new service worker will ge installed. After its potential new resources have been cached and the logic has been loaded it will eventually go in a waiting state.

Handling the new but waiting service worker

Whenever our application gets a hold of a new but waiting service worker, we can safely assume that our application can be updated. So we show a message to the user that an action, like refreshing the page, can be invoked to load the new functionality. By using this approach we keep the responsibility with the user to load the new version.

Immediately activating the new service worker, by using skipWaiting() and/or clients.claim() for all the clients when it becomes waiting can potentially break your application. The functionality from the new service worker could not be compatible with the currently loaded old version of your application. Read more about this consideration in my previous blogpost.

Before we dive further into the code and details for this example, we will go trough the most simple use case that describes the desired functionality:

  1. The user already visited our application before, so there is an active service worker for our application in the browser.
  2. During usage of the application, we deploy a new version. The user clicks a button to check and finds the new version of our application. The new service worker installs and goes into waiting state.
  3. Our application is notified of this new version and shows the user an update button to express his further intent. Meanwhile the new service worker stays in waiting state.
  4. When the user clicks the update button, our new service worker is informed and skips the waiting phase. As soon as the new service worker finishes his caching work and install process it becomes activated.
  5. The application gets notified and refreshes to load the complete new version of the application. If we wouldn't reload we might have the situation that the functionality of our new service worker is not compatible with our currently loaded application.

Listening for a new service worker

Listening for a new waiting service worker using Workbox when we try an update from within the application is a little bit unexpected and different from handling a new waiting service worker after a refresh. For these situations Workbox dispatches lifecycle events like externalinstalling when updates are arriving from "external" service workers. Any version which is not the current Workbox instance or service worker registration can be considered as external.

app-update-external.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { Workbox } from 'workbox-window';

declare const window: any;

@Injectable({
  providedIn: 'root',
})
export class EnvironmentService {

  public newVersionAvailable$: Observable;

  private readonly newVersionAvailable = new BehaviorSubject(false);
  private readonly applicationUpdateRequested = new BehaviorSubject(false);
  // check every 4h if a new version is available
  private readonly swUpdateInterval = 4 * 60 * 60 * 1000;

  private swRegistration: ServiceWorkerRegistration;

  constructor() {
    this.newVersionAvailable$ = this.newVersionAvailable.asObservable();
    this.registerServiceWorker();
  }

  public update() {
    this.applicationUpdateRequested.next(true);
  }

  public async checkForUpdate() {
    // ...
  }

  private async registerServiceWorker() {
    const wb = new Workbox('/sw.js', {});

    // ...

    // we use this waiting listener to handle the update we do
    // based on an interval, user intent or visibility change
    // in this case another service worker became waiting
    wb.addEventListener('externalwaiting', event => {
      // inform any functionality that is interested in this update
      this.newVersionAvailable.next(true);

      // listen to application update requests
      this.applicationUpdateRequested.pipe(
        filter((applicationUpdateRequested) => applicationUpdateRequested),
        first(),
      ).subscribe(_ => {
        // Send a message telling the service worker to skip waiting and
        // become active. We use event.sw.postMessage, and not wb.messageSw,
        // because we want to message the waiting SW and not the currently
        // active service worker
        event.sw.postMessage({ type: 'SKIP_WAITING' });
      });
    });

    // the other service worker became actived!
    wb.addEventListener('externalactivated', () => {
      // If your service worker is configured to precache assets, those
      // assets should all be available now.
      // This activation was on request of the user, so let's finally reload the page
      window.location.reload();
    });

    try {
      this.swRegistration = await wb.register();
    } catch (e) {
      console.log('error registering service worker', e);
    }
  }

}

Let's map our use case written out above onto the code:

  1. We assume an active service worker is available while running a checkForUpdate (line 31) and a new version of the application is found and installing.
  2. Our event listener for externalwaiting (line 43) receives a new event and updates the application that a new version is available. This value is exposed as an Observable in the application, so we can show a notification.
  3. The event listener subscribes and starts listening to the user intent to update the application to the new version (line 48-51). When the user gives his go, an event SKIP_WAITING is sent to the service worker, instructing it to become activated (line 56).
  4. The event listener externalactivated receives a new event, indicating that the new service worker became activated (line 61). Now we can reload to activate the complete new application (line 65)!

The full code for handling all these use cases combined, including the following strategies, is a big pile of code which you can find here.

Visual representation

The animated GIF shown above highlights the steps our code takes when the application is responsible for checking for a new version of the application on the server. As mentioned this can be by user-intent, when the application becomes visible again or by setting up an interval. Let's go trough the animation step-by-step:

  1. Our application creates a new Workbox instance that registers the service worker. The application logic checks if there is already a service worker controlling the page. In this situation, this is the case so we let our application logic know.
  2. By means of user-intent, visibilitychange or the interval our application performs a check, checking the server for a potential new version of the service worker and application.
  3. The new service worker installs and goes into the waiting state.
  4. Our Workbox instance picks up this change via the externalwaiting event listener and updates the application, letting it know there is a new version available. This allows the application to show an update button to the user, giving him the option to load the new version of the application.
  5. The user express his intent and asks for an update of the application. The application logic sends a message SKIP_WAITING to the new service worker, instructing it to advance to the activated state.
  6. The new service worker becomes activated and updates the application trough the externalactivated event, fired by the Workbox instance.
  7. This last step finally instructs our application to do a programmatic reload, activating the newly cached version of our application.

Update application when it becomes visible again

Another strategy to try and update your application can be based on the visibility of your application. As soon as your functionality is loaded, we can start listening to the visibilitychange event on the document as shown in the highlighted code below.

app-update-visible.ts

import { Injectable } from '@angular/core';
import { Workbox } from 'workbox-window';
import { fromEvent } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class ServiceWorkerService {

  // ...

  constructor() {
    this.registerServiceWorker();
    this.registerVisibileChangeListener();
  }

  // ...

  private registerVisibileChangeListener() {
    fromEvent(document, 'visibilitychange').subscribe(() => {
      this.checkForUpdate();
    });
  }

}

Now every time your application gets hidden, for example when opening a new tab in your browser or opening a new application on your smartphone, and you choose to make the tab visible again, or you reopen the application, the app will try to update the service worker.

Update application by setting up an interval

One more other strategy is requesting an update on an interval, for example every 4 hours. This way the user will be presented with the update possibility automatically if a new version has been deployed recently. The extra logic / code for this is a simple 3-liner as highlighted below:

app-update-interval.ts

import { Injectable } from '@angular/core';
import { Workbox } from 'workbox-window';

@Injectable({
  providedIn: 'root',
})
export class ServiceWorkerService {

  // check every 4h if a new version is available
  private readonly swUpdateInterval = 4 * 60 * 60 * 1000

  // ...

  private async registerServiceWorker() {
    const wb = new Workbox('/sw.js', {});

    try {
      this.swRegistration = await wb.register();

      setInterval(async () => {
        this.checkForUpdate();
      }, this.swUpdateInterval);
    } catch (e) {
      console.log('error registering service worker', e);
    }
  }

}

Conclusion

Today we learned a better way of controlling the application with our service worker on the very first load. Instead of refreshing we use the clients.claim() method, but in a way that we won't potentially break our application.

Furthermore, we have explored a few ideas on how to update our application from within the application. This is particularly useful if our applications are long-living and we want to make sure our users get the latest functionalities.

Workbox and service workers in general still have many unknown capabilities that I'm learning about. But while we keep developing we explore new ideas and ways of optimizing our code. I think I will be writing more about service workers in the future :)

Further reading

  1. Workbox Window documentation
  2. High-performance service worker loading
  3. Take control of your scroll: customizing pull-to-refresh and overflow effects
  4. ServiceWorkerRegistration documentation

Special thanks to

for reviewing this post and providing valuable and much-appreciated feedback! I'm open for any other feedback so please let me know!

Contents

  1. Target audience
  2. What will we discuss in this post?
  3. Better detection for a first-time install of a service worker
  4. Using Workbox Window to solve the same problem
  5. Visual representation
  6. Updating the application while it's in use
  7. Update application by user-intent
  8. Handling the new but waiting service worker
  9. Listening for a new service worker
  10. Visual representation
  11. Update application when it becomes visible again
  12. Update application by setting up an interval
  13. Conclusion
  14. Further reading
  15. Special thanks to

By reading this article I hope you can find a solution for your problem. If it still seems a little bit unclear, you can hire me for helping you solve your specific problem or use case. Sometimes even just a quick code review or second opinion can make a great difference.