simon-hofmann.org

Welcome to my mind.
Watch your step, it's a bit messy around here!

The Road To PWA - Part 2

• PWA and JS

This post is part of a multi-part series on progressive web apps (PWAs).

  1. Mobile First Design, Web App Manifest
  2. Intro to Service Workers (you’re here)
  3. Service Worker Caching
  4. Caching Dynamic Content (when it’s done…)
  5. Offline Synchronisation (when it’s done…)
  6. Push Notifications (when it’s done…)

Here we go again!

Welcome back to the second part of The Road To PWA. If you’re new around here, you can find my first post here.

To start off, I’ll summarize the main features of a PWA once more:

  1. Installable: Use a web app from your homescreen
  2. Faster loading: Improved loading due to caching
  3. Offline capable: Even with no connection, my app should work to some extent
  4. Progressivity: Mentioned features are opt-in, the app should work just fine in unsupportive browsers

All elements in this list have one thing in common: They’re in some way relying on service workers.

So, as you might have already guessed, we’re going to learn quite a bit about service workers:

I think this sounds quite interesting, so let’s get going!

The Road To PWA - Visiting Service Workers

To dive right into the topic, a service worker is “just” plain JavaScript. But unlike the rest of our web application, a service worker runs in a separate thread, which brings some implications:

Inside a service worker we’re able to listen and react to certain events. There are lifecycle events as well as events related to our web application. We’ll take a closer look on these events in a bit.

So, to cross off the first two elements on our list of things we’re going to talk about, I like to see service workers in general like an interceptor. It’s a piece of JavaScript which runs in a separate thread and sits right between our application and “the internet”. We’re able to react to a service worker’s lifecycle events, which is perfect to perform things like pre-caching of assets, but it’s also possible to intercept any network request which is performed in our web application via a service worker. This allows a service worker to manipulate just about everything in an intercepted request (request URL, headers, payload, response, etc.), but also gives it the possiblity to cache dynamic data. The best thing about this is, when done carefully, you don’t have to apply any changes to your existing application despite adding the register() call to enhance it by using service workers.

The Service Worker Lifecycle

Now that we know what service workers are capable of, let’s have a closer look on their lifecycle.

Service worker lifecycle

Whenever a user visits our PWA, its browser will parse our index.html page. Somewhere along this page, there should be a <script> tag which includes code to register a service worker.

<script src="./src/js/registerserviceworker.js"></script>

Inside registerserviceworker.js a service worker is registered by calling

navigator.serviceWorker.register($pathToServiceWorkerFile);

HTTPS Only

During development it’s fine to install a service worker from localhost. Whenever we’re ready to publish our PWA, we require a proper HTTPS setup.

As mentioned earlier, service workers are really powerfull with regards to request manipulation. You wouldn’t want to install such things from insecure locations.

Lifecylce Stages

After calling the register() method, a service worker goes through the following three stages:

  1. Install
  2. Waiting
  3. Activate

Let’s take a closer look at each of these stages!

Stage 1: Installing a Service Worker

Whenever we try to register a new service worker, or applied changes to an already registered one, an install event is fired. This event is one of the service worker lifecycle events we can attach to and it’s perfectly suitable to perform e.g. pre-caching for our application. event.waitUntil() gives us the possibility to manually prolong the install stage until we’re done with our initial setup.

We’ll discuss pre-caching and caching in general in my next post.

Stage 2: Waiting for Activation

Immediately updating a service worker might be a bad idea. In case we updated its behaviour to e.g. return a different response than it did before, we’d want a “clean cut” before the new version gets activated.

To achieve this clean cut, every client which is currently under control of our service worker has to be reloaded*. Once this is done, our service worker will enter the next stage.

Stage 3: Activating a Service Worker

Once we enter the third stage we’re sure that none of the clients is controlled by an active service worker and that therefore it’s safe to activate our new one.

Similar to the install event, we’re able to manually prolong this stage by calling event.waitUntil(). By doing so, we’re able to perform cleanup tasks to remove outdated data from other workers.

A typical task to do in this stage is to clean up eventually outdated caches. Once again, we’ll take a closer look on this in my next post.

Service Worker Registration

The following snippet shows the content of one of my registerserviceworker.js files:

import { capabilities } from "./capabilities";

console.log("Trying to register service worker.");
if (capabilities.sw) {
  navigator.serviceWorker.register("../../sw.js").then(registration => {
    console.log("Registered service worker with scope: " + registration.scope);
  });
} else {
  console.log("Service workers not supported, skipping registration.");
}

This rather short snippet of code actually contains quite a bit to discuss.

navigator.serviceWorker.register("../../sw.js");

This line is the actual responsible for registering our service worker. In my example, the service worker code in sw.js is located in my web apps root folder, two levels above the registration code. While this might seem nothing special at all, it actually leads us to an important topic.

Service Worker Scope

Each request we issue in our application has an origin. And the service worker scope configures, which origins fall under its control. Per default, a service worker’s scope is set to its location, so when it’s located in our root level it controls to whole scope and we’re able to intercept each request. When set to e.g. ./other/scope, we’d only be able to intercept request originating from https://toplevel.domain/other/scope. The SW scope is configured by passing a config object to the register() call.

{
  scope: "./other/scope";
}

Generally speaking we’re only able to configure a scope which is at most on the same level as our service worker file, or lower. So it’s not possible (at least not without additional work) to configure a / scope for a service worker located in e.g. /src/js/sw.js.

Just in case we really want to configure a scope above our service worker file, there’s still a way to achieve it. Assuming we’re able to configure our webserver to our likings, we’ll have to add a special header to our service worker resource. By adding the special header Service-Worker-Allowed we’re able to set an upper path for our service worker’s scope. Have a look at the service worker spec for more info. To be honest, I’m just placing my service worker file on root level to avoid additional config work.

Service Worker Support

Another detail worth mentioning is the following import:

import { capabilities } from "./capabilities";

I introduced this module for convenience.

export const capabilities = {
  sw: "serviceWorker" in navigator,
  idb: "indexedDB" in window,
  sync: "serviceWorker" in navigator && "SyncManager" in window
};

Service workers are gaining more browser support, but they’re not supported by most older browsers. So in order to use their functionality, we should first check if the current browser is supporting service workers.

There are some more checks which we’ll deal with later, at the moment we’re just checking for

"serviceWorker" in navigator;

caniuse.com provides an overview which browser versions are supporting service workers.

Conclusion

In this post we learned about service worker capabilities and their lifecycle. A little code example demoed how to register a service worker and how to configure an optional scope. We talked about how we can manipulate the max. scope by adding the Service-Worker-Allowed header and how to check for browser compatibility.

What’s next?

In my next post, I’ll take a deep dive into service worker caching.

By the end of my next post, we’ll have everything to make our PWA installable on mobile devices!

See you next time!

Simon