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:
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.
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
- register a service worker script inside our application
- unregister the worker again and add a function to monitor if a worker is currently active in the user interface
- 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);
}
};
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.
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();
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);
}
};
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');
}
};
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();
});
Back to your app, the image below now shows an active service worker instance.
Once you click on Unregister
, you can also monitor the change in your devtools
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:
-
this
describes the object that owns the function calling it (read more about the topic in general on MDN). - In the context of a service worker, it is represented by the
ServiceWorkerGlobalScope
- object
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'];
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);
};
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);
}
});
};
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)]);
});
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));
});
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.
- Check all active service worker caches (not just the currently active one, but all!) for an already cached response.
- If it exists, return it - no network communication happens and the http-request resolves. If it does not exist, move ahead.
- Check if the user is online (via
navigator.onLine
property) - 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
- 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');
}
};
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:
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.
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 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