Should you zip your precache assets?

Lion Ralfs — Posted on

Precaching is a term that's being used to describe the process of downloading files during the installation stage of a service worker, which are then added to the Cache Storage (for offline use, for example).

I came across an interesting idea lately, where instead of downloading your precache assets individually, you would package them into a zip archive, download it in the service worker where you unzip and add each file to the cache manually.

download, then unzip, then add to cache

This might have a few advantages as well as some disadvantages:

Pros

Cons

I came across this pattern as part of the Service Worker Cookbook and was wondering if there was any significant performance gain from doing this instead of the "vanilla" way of doing something like this:

// in your service worker:
self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('precache-v1').then(function (cache) {
      return cache.addAll(['offline.html', 'main.css', 'bundle.min.js']);
    })
  );
});

Let's stick to calling this the "vanilla" approach. As for the alternative, let's call it the "cookbook" version. Here's a rough idea of how it works:

  1. At build time, generate an asset zip archive.
  2. During the service worker installation stage, download the zip package and use a client-side zip library to extract the individual files as Blobs.
  3. For each extracted file (filename + Blob), figure out it's Content-Type and path (see below).
  4. Construct a Response object, and store it in the cache, using Cache.put

In the cookbook version, the service worker carries a dictionary for looking up Content-Types by file extensions:

let contentTypesByExtension = {
  css: 'text/css',
  js: 'application/javascript',
  png: 'image/png',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  html: 'text/html',
  htm: 'text/html',
};

To construct the path to where the actual asset is stored, the following function is shipped along with the rest of the service worker code:

/**
 * Assumes your service worker is called 'worker.js'
 * Example:
 *
 * getLocation('main.css') --> 'https://lionralfs.dev/main.css'
 */
function getLocation(filename) {
  return location.href.replace(/worker\.js$/, filename || '');
}

Glued together, this approach looks pretty much like this:

let response = new Response(dataAsBlob, {
  headers: {
    'Content-Type': getContentType(entry.filename),
  },
});
cache.put(getLocation(entry.filename), response);

Compression

Let's take a moment to talk about compression. zip is compressing using deflate by default. Since I plan on serving my assets with the Brotli compression, this is not a fair comparison. So, I added a third approach which I'll call the "zip of brotlis", where I run all precache assets through Brotli, and then zip them using no compression (zip -0 or zip --compression-method store). This should result in an even smaller archive, due to how good Brotli is. However, this means also shipping code capable of decompressing Brotli in the service worker. The "zip of brotlis" version also slightly differs from the cookbook (pure zip) method in that it also includes a meta.json file which carries along the Content-Type and path of each asset. This removes the need of having to guess based on file extension. This is how I constructed the meta.json file:

{
  // the key is the name of the file in the zip archive,
  // the value is an array of
  // [0]: the path of where to store the asset using cache.put()
  // [1]: the content type
  "offline.html": ["/precache/offline.html", "text/html"]
}

This results in three approaches in total. So, I set up a benchmark to test out some scenarios. Here's my setup for comparing the approaches:

Benchmark setup

Generally speaking, the comparison boils down to the download time (multiple requests) vs. download time (single request) + unzip time.

Suitable precache assets

For all of my tests I stuck with the following list of assets, which I believe to be a reasonable representation of modern web development practices. I chose a few small SVG icons, React/ReactDOM in their minified production version as well as all of TailwindCSS, fully minified. Along with those resources, I added the Roboto web font in its woff2 version, in regular and 700 weight. Lastly I included an offline.html page. These are their respective file sizes:

FileSize (uncompressed)Size (Brotli using `--best`)
arrow-up-svgrepo-com.svg269 Bytes179 Bytes
attachment-svgrepo-com.svg616 Bytes319 Bytes
backspace-svgrepo-com.svg556 Bytes277 Bytes
ban-svgrepo-com.svg295 Bytes193 Bytes
bar-chart-alt-svgrepo-com.svg290 Bytes155 Bytes
bar-chart-svgrepo-com.svg376 Bytes199 Bytes
board-svgrepo-com.svg247 Bytes164 Bytes
meta.json (only for zip of Brotlis)1119 Bytes250 Bytes
react-dom.production.min.js120585 Bytes34550 Bytes
react.production.min.js11440 Bytes4019 Bytes
roboto-v29-latin-700.woff215828 Bytes15828 Bytes (unchanged)
roboto-v29-latin-regular.woff215688 Bytes15688 Bytes (unchanged)
tailwind.min.css2934019 Bytes72895 Bytes
offline.html1550 Bytes439 Bytes

The woff2 files are unchanged because woff2 uses Brotli as its compression anyways.

In total, this sums up to the following sizes:

Local server

To reduce as much latency as possible, I set up a server in my local network, on a Raspberry Pi 4 to serve static assets as well as the zip archives. I had to jump through some hoops here to get HTTPS working, since Firefox only supports Brotli over HTTPS. I tried serving the assets from a server running on localhost, but this impacted the benchmarking tool I was using in combination with throttling the network speed.

My local machine (running the tests itself) is a 2019 MacBook Pro with the following specs:

Throttle network

To emulate real world network conditions, I used throttle with the following profiles:

Benchmark in different browsers

I'm going to test the big three engines: Blink (Chrome), Gecko (Firefox) and WebKit (Safari). By using tachometer to benchmark in different browsers I'm trying to see if there are any differences between browsers themselves (even for the same approach). This might catch some browser quirks. During some experimenting, I noticed Safari has really fast cache writes in some cases.

Isolate code

Assuming that there is no significant performance difference between running the code in a service worker or on the main thread, I set up the tests in a single HTML file like this:

<script>
  (async () => {
    performance.mark('before');
    await doTheWork();
    performance.mark('after');
    performance.measure('measurement', 'before', 'after');
    let total = performance.getEntriesByName('measurement')[0].duration;
  })();
</script>

The doTheWork() obviously differs between the approaches. To summarize, these are the approaches:

As mentioned above, I purposely excluded the cache.put() operation, as Safari is somehow significantly faster. This should not affect the comparison, as all approaches would need to execute it. Here's the results:

Benchmark results

Overall, it looks like browsers are behaving similarly for the same approach. The cookbook version seems to be much worse on slower network conditions, this can probably be attributed to the extra bytes of the archive, since Brotli isn't used here. Only for the 4g profile, it seems that the zipped Brotli version beats the vanilla approach, which seems weird but could be an error in my benchmark. There also seems to be pretty much no difference between the 50k and 100k versions.

Conclusion

So, should you zip your precache assets? Well, probably not, browsers are actually pretty good at downloading multiple files in parallel, as well as decoding them. But if you really wanted to, use an approach similar to my "zip of Brotlis". Also, all of these tests were run on a relatively new MacBook Pro with a good CPU, so these results would look different on devices with weaker CPUs, potentially proving the vanilla version to be even better. You should probably also use some sort of RUM (Real User Monitoring) to track and measure how these choices actually impact your users. This usually gives you a better picture than purely synthetic tests or benchmarks.

I might also have screwed up somewhere and my benchmark is flawed. I have set up a GitHub repo with all of the examples and the benchmark itself, if you want to check it out. An aspect which might be interesting to look at is how the size/amount of precache assets impacts the performance. Also, the Web Package specification mentions a similar approach, which could be further explored.

In case you didn't know, v8 uses a heuristic to automatically store the optimized, compiled bytecode version of scripts that are added to the cache during service worker installation. All of the approaches mentioned here keep this benefit.

One main takeaway from all of this is that you should probably just use good compression. Brotli is incredibly powerful at compressing files, and since all modern browsers support it, you don't even have to ship your own client-side Brotli library.