This post is part of a multi-part series on progressive web apps (PWAs).
- Mobile First Design, Web App Manifest
- Intro to Service Workers (You're here)
- Service Worker Caching
- Caching Dynamic Content
- Offline Synchronization
- Push Notifications
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:
- Installable: Use a web app from your homescreen
- Faster loading: Improved loading due to caching
- Offline capable: Even with no connection, my app should work to some extent
- 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:
- What they are in general
- What they're capable of
- Their lifecycle
- How we can register one
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:
- Service workers do not have DOM access
- Communication between a service worker and the page happens via
postMessage()
- Service workers keep running even when:
- The user has left / closed the page
- The user has closed its browser
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 at 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 possibility 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 at their 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 powerful regarding request manipulation. You wouldn't want to install such things from insecure locations.
Lifecycle Stages
After calling the register()
method, a service worker goes through the following three stages:
- Install
- Waiting
- 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 behavior 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.
- In case we really want to, we can override this behavior by calling
self.skipWaiting()
inside the service worker to immediately 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 possibly outdated caches. Once again, we'll take a closer look at 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 the whole scope, and we're able to intercept each request.
When set to e.g. ./other/scope
, we'd only be able to intercept a 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 web server 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 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.
- Pre-caching
- Dynamic caching
- Caching dynamic content
- Cache utilities
By the end of my next post, we'll have everything to make our PWA installable on mobile devices!
See you next time!
Simon