Service workers and caching strategies explained
By: Francesco Leardini
Tags: Angular, Web Development, JavaScript, Performance, Pwa

Photo by Clark Tibbs on Unsplash

This article will cover other PWAs important topics: service workers (SW) and caching strategies that we can implement to further leverage the full potential of PWAs.

Excited? Let’s start!

What is a service worker?

A SW is a kind of web worker…or, with other words, a service worker is simply a javascript file acting as a proxy between our web application and the network. It can intercept http requests and serve the responses from the network or from a local cache, according to which caching strategy we implemented (more details later).

Let’s list some SW characteristics:

Secure

Service workers will function only on HTTPs connection.

This is a safe decision, because otherwise we would easily expose our application to man-in-the-middle attacks. Let's just imagine what might happen if anybody could substitute our SW with a manipulated one...scary, isn't it?

On the other side, localhost is considered secure, allowing to test the application before deploying it.

If we work with Angular though, we cannot use the ng serve command to build and serve our application locally, as it does not work with service workers. In this case we have to use an HTTP Server of our choice, for example http-server package or the Web Server Chrome extension.

No direct DOM interaction

Service workers cannot access the DOM directly. They can, however, communicate with the pages under their scope through the postMessage interface. Those pages can then manipulate the DOM allowing an indirect access.

Non blocking

SW runs on an independent thread, separate from the one used by our application. From this the definition “non blocking”.

Indeed we do not want that heavy load operations or crashes would affect in any way the performance of our web app.

The capability of running in the background is also the reason why we can deliver push notifications to our users even if the browser and our application are closed. We just need to successfully register the service worker a first time on the user’s side.

Life cycle

if ('serviceWorker' in navigator) {

navigator.serviceWorker.register('/serviceWorker.js')

.then(function(registration) {

// The registration was successful

})

.catch(function(err) {

// The registration failed

});

}

As we saw previously, not all browsers support SW. Therefore we first need to check whether the service worker API is available before attempting to register the SW when the user accesses our app and the page is loaded.

The schema above describes the different lifecycle steps of a service worker.

During the registration phase if an error occur or the SW file cannot be fetched, the whole operation is canceled.

The register method will be newly triggered when the user loads the page again. The browser is able to identify whether the SW is already installed or not and call the method accordingly.

If you open the DevTools now (F12 — in case you use Chrome), access the Application tab and click on the service worker section, you will find the DEV SW registered in our browser:

During the install step, pre-fecth operations are typically executed, whose goal is to ensure target assets are downloaded and made already available in the cache for the SW. This step happens only at the beginning of a SW lifetime or when a new version is delivered.

Once registered, a SW does not remain constantly active. In fact the browser can unpredictably terminate it and reactivate it again when an event needs to be triggered. That’s the reason why, if we need to persist a state used within the service worker (I do not mean caching assets or API requests here), we should better use IndexeDB, or a similar solution.

Unregister a service worker

To remove a SW, you can click on the Unregister link inside the developer tools of the browser.

Or it is also possible programmatically:

navigator.serviceWorker.getRegistration()

.then(function(registration) {

if(registration){

registration.unregister()

.then(

function(success) {

// if success = true, unregister was successful

});

}

});

Note: unregistering a SW will not free its cache. For this we have to click the “Clear storage” button in the Application panel of the developer tools (Chrome):

Via code we can use caches.delete() :

if ('caches' in window) {

caches.keys()

.then(function(keyList) {

return Promise.all(keyList.map(function(key) {

return caches.delete(key);

}));

})

}

The caches.keys() method returns the keys of the CacheStorage , an interface representing the storage for the Cache objects that can be accessed by the service worker.

Caching strategies

There are several different caching strategies we can adopt to improve performances.

If a data request is cached, we can deliver it without the need to access the network at all. This brings different advantages: on one side the response will be much faster, and on the other side we can provide the data even when the client is offline, since we have it already available locally.

Before starting though, we need to understand that we need to instruct the SW on how to cache data requests, since this isn’t done by default.

General caching strategies

Below some common caching strategies, not bound to any framework.

Cache only

Diagrams source: Google web fundamentals guide

self.addEventListener('fetch', function(event) {



event.respondWith(caches.match(event.request));

// If the requested data isn't in the cache, the response

// will look like a connection error

});

Probably the simplest one. The SW expects to find the requested assets already here. This strategy can be used for the static resources that constitute our “app shell”. Usually those are fetched while the SW is installing, in order to be available in the cache after this phase.

Network only

self.addEventListener('fetch', function(event) {

// We can put some custom logic here, otherwise

// the request will follow the default browser behaviour

});

This strategy is exactly the opposite of the previous one: we always access the network, without even querying the cache. This is best suited for logs or anything we do not need to make it available offline.

Stale while revalidate

self.addEventListener('fetch', function(event) {

event.respondWith(

caches.open('www.my-web-app.com')

.then(function(cache) {



return cache.match(event.request)

.then(function(response) {

var fetchPromise = fetch(event.request).then(function(networkResponse) {



cache.put(event.request, networkResponse.clone());

return networkResponse;

})



// response contains cached data, if available

return response || fetchPromise;

})

})

);

});

Similarly to the cache only strategy, the goal is to ensure a fast responses by delivering the data from the cache.

However, while the client request is served, a separate request is triggered to the server to fetch a newer version, if available, and store it into the cache. This way, while we guarantee fast data delivery on one side, we also update the cached data on the other, so next requests will receive a more actual version.

Angular caching strategies

Angular provides only two kind of caching strategy:

Performance (default)

Here the goal is to optimise the response time. If a resource is available in the cache, this version is delivered. Otherwise a network request is executed to fetch and then cache it.

This strategy is adapt for resources that do not change often, like the user profile images. In these cases we want to provide the fastest response to the user without worries to deliver potentially obsolete data.

Freshness

When it is required to deliver the latest data from the network. We can specify a timeout after which the request falls back to the cache and tries to deliver the required data from there.

A typical use of this strategy is when we need to deliver the mostly up to date information that changes frequently. We can think to an application dealing with stock prices or blog articles written.

I won’t go too much in detail for the Angular strategies since we will see them more in detail in the next post, where we will also write some code to implement both of them.

How much data can we store?

The amount available is not the same in any context, but it differs for each browser according to the device and storage conditions.

Chrome and Opera base the available storage per origin (our site domain). To verify the remaining space we can use the Quota Management API :

navigator.storageQuota.queryInfo("temporary")

.then(function(info) {

console.log(info.quota);

// It gives us the quota in bytes



console.log(info.usage);

// It gives us the used data in bytes

});

Some browsers starts prompting the users whether they agree in continuing storing further data when specific thresholds are reached.

Firefox after 50MB of data stored

Safari mobile can use only 50MB max

Safari desktop does not have storage limits, but starts requesting for confirms after 5MB stored.

These initial posts focused on the theoretical fundaments of PWAs.

Next article will present us tools to create PWAs.

Moreover we will create a demo with Angular and I will guide you step by step to make it a complete progressive web app. You can then use it as starting point for your next project!

Read more articles about PWAs