Improve (offline) user experience with Service Worker Toolbox

In the last post I introduced a push notification feature for this blog, which requires a Service Worker. This post is about making more use of this Service Worker by improving the (offline) user experience with Service Worker Toolbox.

In case you don't know what this Service Worker thingy is, I can recommend reading this and this article.

Feature overkill?

In order to cache resources and intercept requests there's quite some code to write. For example, the various lifecycle events of a Service Worker need to be handled, and things like precaching, URI cache patterns and caching strategies (cache first, network only etc.) are probably features you would like to have.

While Service Workers are definitely more complex and low-level than the existing AppCache API, for many typical use cases it can become cumbersome to write more or less "the same code" and you may ask yourself where's a simple higher-level API/library for this, as described in this article by Maximiliano Firtman.
But while he suggests to improve and revise the AppCache API by fixing the flaws it has and adjusting it to today's challenges, I think Service Workers are the way to go. Reduced to the offline experience feature, they provide a much more flexible approach than the declarative approach of AppCache, which can deal with simpler offline use cases in a good way, but as soon as it gets more complex its limit is reached. By being more low-level, a Service Worker can be adjusted to match your use case more easily and precisely. In addition, if you already have a Service Worker running, you can benefit from its other features as well. And for simple common use cases there's an awesome library I can wholeheartedly recommend:

Introducing Service Worker Toolbox

Service Worker Toolbox is a collection of tools and helpers for working with service workers actively developed by Google. Especially stuff like caching strategies or route matchers (like Express routing) is provided by this toolbox, so you can use it your own code.

Configuration

What really blew my mind was how easy this library can be added to your code and how readable the code you'll write is.
To install it just take a look at this section, you can use npm, bower or clone it with git. Once you've got the code, you can take advantage of their Service Worker companion script, which helps you to register a simple Service Worker on the root scope.
In case you already have an existing Service Worker the library can be imported with the importScripts(<path-to-script>) function for example.

Usage

Now you're good to go to define your cache/cache strategies for specific routes. For example, if you want to cache resources of an assets directory, this code could be used.

self.toolbox.router.get('/assets/(.*)', self.toolbox.cacheFirst, {
  cache: {
    name: 'asset-cache-v1',
    maxEntries: 10
  }
});

In the first line you define the URL pattern (Express-style or Regex matcher are supported!), which the requests are matched against. If there's a match (e.g. GET /assets/dist/vendor.min.js?v=241), the request handler cacheFirst (2nd parameter) is invoked along with some options {} (3rd parameter). The cacheFirst-handler is provided by the toolbox and accesses the cache primarily. If it doesn't have an entry for the requested resource, a network request is started and if it succeeds, the response will be returned and the cache entry gets updated.
The other available cache strategies can be found in this section of the documentation.
As third parameter an object is given, which configures the cache which will be used by the request handler. The cache will be named to asset-cache-v1 and it will never grow larger than 10 entries. Behind the scenes an Indexed DB is created which persists and manages the state (since state cannot not be handled reliable in a Service Worker) of the cache entries and removes the least-frequently used entry automatically! And all this functionality is expressed in a very readable and abstract way 🚀

The library provides even more such sophisticated features, like pre-caching, max age for cache entries, various routing methods and the possibility to write your own request handlers and combine them with the toolbox's functionality.

Use case for a Ghost blog

Now I want to show you how I used the service worker toolbox for this Ghost blog.

Assets

Ghost caches resources heavily which is why you should make use of the asset helper for cache busting. Since these resources have their own caching mechanism, you can use a route definition with cacheFirst:

self.toolbox.router.get('/assets/(.*)', self.toolbox.cacheFirst, {
  cache: {
    name: staticAssetsCacheName,
    maxEntries: 10
  }
});

The same applies to the static google fonts, which are imported via a <link> tag:

<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Merriweather:300,700,700italic,300italic|Open+Sans:700,400" />

Returns something like this:

... Static Google fonts
/* latin */
@font-face {
  font-family: 'Merriweather';
  font-style: normal;
  font-weight: 300;
  src: ...url(http://fonts.gstatic.com/s/merriweather/v11/ZvcMqxEwPfh2qDWBPxn6nkZRWJQ0UjzR2Uv6RollX_g.woff2) format('woff2');
}
...

The URI does not point to a 'static' CSS file, since the content could change if the merriweather font is upgraded and is linked to v12 instead of v11. However, these referenced font files do have a cache-busting mechanism included in their URI (v1, v2, etc.), so a cacheFirst network strategy can be applied to these resources.

// Cache all static vendor assets, e.g. fonts whose version is bind to the according URI
self.toolbox.router.get('/(.*)', self.toolbox.cacheFirst, {
    origin: /fonts\.gstatic\.com/,
    cache: {
      name: 'static-vendor-cache-v1',
      maxEntries: 10
    }
  }
);

In the code snippet you can also see that you can specifiy a regex to make the route handler only match for the origin fonts.gstatic.com.

For the href in the <link> tag from the snippet above you could use the fastest request handler from Service Worker Toolbox, which reads an entry from cache and starts the requests in parallel. Whichever returns first its content will be used as response, but if/when the network requests succeeds, the cache entry gets updated.

self.toolbox.router.get('/css', self.toolbox.fastest, {
  origin: /fonts\.googleapis\.com/,
  cache: {
    name: 'dynamic-vendor-cache-v1',
    maxEntries: 5
  }
});

Note that the origin is matched against a different regex.

Content

Other resources that do not have a cache-busting URI and need to be updated are content images in Ghost. This is pretty straightforward, though you could argue between the fastest and networkFirst strategy.

self.toolbox.router.get('/content/(.*)', self.toolbox.fastest, {
    cache: {
      name: `content-cache-v1',
      maxEntries: 50
    }
  }
);
Fetching documents

Lastly, let's define a route for the actual blog post documents. Since Ghost creates the URI for them directly after / and delivers them without a file extension, a simple route matcher is not sufficient so the code becomes a bit more complex.

The first thing we need to do is to match all GET requests from the blog's origin. Then we need to make sure, that the requested resource is a document (Content-Type: text/html) and its URI does not contain the path /ghost, since this is the path where the backend lives which should not be cached. If all these checks pass, the request is given to the fastest network handler (networkFirst could be used as well) from sw-toolbox. If not, the request gets executed normally as if there is no Service Worker intercepting it (networkOnly handler).

self.toolbox.router.get('/*', function(request, values, options) {
  if (!request.url.match(/(\/ghost\/)/) && request.headers.get('accept').includes('text/html')) {
    return self.toolbox.fastest(request, values, options);
  } else {
    return self.toolbox.networkOnly(request, values, options);
  }
  }, {
    cache: {
      name: `content-cache-v1',
      maxEntries: 50
    }
  }
);

Also you could add an additional error handling by appending .catch() to the promise chain to provide a useful error message when the content cannot be retrieved. This is possible because all handlers by sw-toolbox return a promise.

Further improvements

Things which I did not add to the blog but could be pretty cool are a pre-caching setup for the root page and the about page for example and providing a sensible 'You are offline' page, if network and cache fail to retrieve content.

Wrapping it up

As I said, the ease of use of Service Worker Toolbox really impressed me. The code for route matching and applying the right network strategy is super small und truly readable, but easy to extend as well. In a short time, I was able to set up different network strategies for various resources thus improving the load performance and providing some offline functionality as well. To my mind, by using Service Worker and Service Worker Toolbox instead of AppCache, you'll get nearly the same simplicity concerning the setup for usual use cases, but on top of that much more flexibility and power to support more complex ones.

What do you think? Did you have other experience or problems with this approach? Feel free to contact me or drop a comment below.


Useful resources

Comments on this post


comments powered by Disqus