Caching with a ServiceWorker

0

Next up, caching..

This is the second part of a three part blog series. You can find the first part here. In this part, we will talk about caching.

Ok, so we’ve seen:

  • How to register a ServiceWorker
  • What you can do to intercept an HTTP request
  • How to answer a request from within the ServiceWorker.

This is nice, but you don’t want to copy every resource into the source code of your ServiceWorker.
So what can you do, to cache the resources? Preferably preemptive.

But first; Fetching data

XHR (Ajax) requests are a pain to set up, as you can see in this “simple example”. First you have to create an XMLHttpRequest Object, then register listeners. Handle all different kinds of events and status codes. Etc, etc..
Mostly we wrap it with a library or a wrapper function as fast as possible, so we don’t have to deal with them.

Along with ServiceWorkers a new API for fetching XHR requests was introduced. Usable in both the ServiceWorker and the regular JavaScript scope.
Although you should be careful in your main JS app if you need to support legacy browsers! If a browser supports ServiceWorkers, it will support this API too. So in that scope you can use it freely.

This API is the Fetch API. I won’t go into details about this API, but an XHR request can become as simple as: fetch(‘/api/data.json’). This function returns a promise, which resolves to a Response object.

This new response object also has some new goodies. Like a couple of unpacking commands for things like formData/text/json etc. So, fetching and parsing a json resource simply becomes:

fetch('/api/data.json')
    .then(resp => resp.json())
    .then(data => {
        // Do something with that data
    })

Storing data

Fetching data is easy, but if we want to cache it, storing is just as critical. And the ServiceWorker has access to a couple of API’s that can help with that. All async of course.

The main two API’s are IndexedDB and CacheStorage/Cache API. Both of them are available from within both the ServiceWorker and the main application.
IndexedDB is an API we have had for some time now. It isn’t made for caching HTTP, but can be really useful if you want to do something fancy with your data requests.

Warning: You can also cache by leaving items on the ServicWorker scope itself. But caching anything in the ServiceWorker scope is a bad idea. Idle ServiceWorkers can be killed and respawned at any time, clearing any items you put into the scope.

CacheStorage

The CacheStorage API is made especially for caching HTTP requests. For versioning purposes you can have multiple caches, all available by name. These versions can both hold the same resources, or different ones. As shown below.

CacheStorage example with multiple caches and file versions.

CacheStorage example with multiple caches and file versions.

So in the main CacheStorage object there are two Caches, `Version_1` and `Version_2`. They both contain the main app files. Normally you put the newer files in the cache and remove the old cache afterwards. But you can do whatever you want with this. You can even put different parts of the application in different caches.

The CacheStorage object is available under this.caches in the ServiceWorker and since everything is async, you get the cache you want in a promise. Getting access to one of your caches looks like this:

this.caches.open('Version_1')
    .then(cache => {....});

Ok, that was easy. I have the cache, what now?
Now we have to put the answer from the request in there with the put() method. This method expects a request and a response, to act as a key/value in the cache. Caching a index.html would result in:

let resp = fetch(‘index.html’);
let cache = this.caches.open(‘Version_1’);
Promise.all([cache, resp])
    .then(([cache, resp]) => cache.put(‘index.html’, resp));

The fancy approach

That’s all very nice, but isn’t there an easier way? Of course there is! A Cache has the add() and addAll() methods, as a shorthand for the code above. Those methods add resources by fetching them and putting them in the cache. All the code above can be replaced by:

this.caches.open(‘Version_1’).then(cache => cache.add(‘index.html’);

Retreiving data

Ok, pages added, check! How about retrieving them. The easiest way is with match().
This method tries to match any key in the cache and returns (a Promise with) the first matched response. Even better, this method is also available on the whole CacheStorage object. So you can do both:

this.caches.open(‘Version_1’)
    .then(cache => cache.match(event.request));

To get the response from ‘Version_1’ and

this.caches.match(event.request);

If you don’t care which cache you use.

As long as the request doesn’t match multiple entries, they should both have the same result.

In practice

In this plunk you can see ServiceWorker caching in action. I’ll explain what is happening in every step.

The Application

It is too simple to be called an application, but app.js does introduce an xhr request that is displayed on the page. So there are resources from both static files and a dynamic sources.

The application uses the `Fetch` API to get the JSON file, after the page is loaded. Then it parses that JSON and uses the object to fill the DOM.

Resources

First of all, we need to know which pages to cache. For convenience we have put those url’s in a separate variable.

const pages = [...];

Caching

Then we need to cache them. Caching of vital resources, like index.html without which a website won’t work at all, is best done in the ‘install’ event. We even want the ‘install’ event to wait for this caching.
Why is that? Well, without index.html we might as well not have a ServiceWorker. So we only want the ServiceWorker to be installed if that page is cached.
Luckily these events are ‘extendable events’, which means they have a method waitUntil(). Which, you guessed it, waits until the given Promise is resolved before finishing the event.

this.addEventListener(‘install’, event => {
    let cachePromise = this.caches.open(‘v1’)
        .then(cache => cache.addAll(pages);
    event.waitUntil(cachePromise);
});

Fetching

We don’t always want to cache our fetches. To prevent us from trying we have put up two safeguards.

  • Only GETs; GET requests are used to fetch data, with no side effects. So we can safely cache those. But if there is another kind of request. We should just let it through.
    Especially on Plunker, since they POST code changes to your page. If that doesn’t come through. You can’t update your page anymore.
  • Not /api/; Everything behind ‘/api’ comes from our database and could change. So if there is a request for that data, we want to see the latest version of it.

Now we try and fetch our page from the cache. Since we only have one cache, we don’t care where it comes from and ask the main Cache object.

let cachePromise = caches.match(event.request);

If the result isn’t found in the caches, the result of the Promise will be empty. When this happens, we want to try and fetch the resource the old fashioned way. So below we return the response, or if it’s not there, we return the fetch Promise.

let responsePromise = cachePromise.then(resp => resp || fetch(event.request));

The final Promise is our answer to the request. So we feed it to the response of the event. This also tells the browser to stop any request it wanted to send.
This is also why it’s important to do this now, and not in a .then() of another Promise. If we would do that, the browser wouldn’t know we were up to something clever and would just handle the request the way it normally does.

event.respondWith(responsePromise);

And voilá, our pages are cached. Of course you can create any caching strategy within your code, with this being just about the most basic one.

Of course what you want to cache will differ from application to application. But in our case you can play with it.
If you load the page, and change some of the data, it will show in the output. If you change the title in index.html though, it won’t. It will still use the cached index.html.

SW-Toolbox

If you want to do caching with a ServiceWorker, I can recommend you to look at SW-Toolbox, from the guys behind Chrome.
Because the patterns used for that are similar every time, they have made them even easier.

Up Next

Next time we will talk about the ServiceWorker lifecycle and how you can use it to your advantage.

About Author

Merijn studied Electrical Engineering at the TU/e, where he started programming in C/C++. After his studies he got involved in several software development projects, using a wide array of programming languages like JavaScript, Go, Dart, Ruby on Rails and PHP. Since he started working at AMIS, Merijn's main focus has been Web Development.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.