Should you zip your precache assets?
Lion Ralfs — Posted onPrecaching 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.
This might have a few advantages as well as some disadvantages:
Pros
- Single HTTP-request
- Uniform versioning (app-like)
Cons
- Need extra processing (unzipping)
- Need to ship extra code to unzip, increasing service worker code
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:
- At build time, generate an asset zip archive.
- During the service worker installation stage, download the zip package and use a client-side zip library to extract the individual files as Blobs.
- For each extracted file (filename + Blob), figure out it's
Content-Type
and path (see below). - 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:
File | Size (uncompressed) | Size (Brotli using `--best`) |
---|---|---|
arrow-up-svgrepo-com.svg | 269 Bytes | 179 Bytes |
attachment-svgrepo-com.svg | 616 Bytes | 319 Bytes |
backspace-svgrepo-com.svg | 556 Bytes | 277 Bytes |
ban-svgrepo-com.svg | 295 Bytes | 193 Bytes |
bar-chart-alt-svgrepo-com.svg | 290 Bytes | 155 Bytes |
bar-chart-svgrepo-com.svg | 376 Bytes | 199 Bytes |
board-svgrepo-com.svg | 247 Bytes | 164 Bytes |
meta.json (only for zip of Brotlis) | 1119 Bytes | 250 Bytes |
react-dom.production.min.js | 120585 Bytes | 34550 Bytes |
react.production.min.js | 11440 Bytes | 4019 Bytes |
roboto-v29-latin-700.woff2 | 15828 Bytes | 15828 Bytes (unchanged) |
roboto-v29-latin-regular.woff2 | 15688 Bytes | 15688 Bytes (unchanged) |
tailwind.min.css | 2934019 Bytes | 72895 Bytes |
offline.html | 1550 Bytes | 439 Bytes |
The woff2 files are unchanged because woff2 uses Brotli as its compression anyways.
In total, this sums up to the following sizes:
- sum of individual Brotlis: 144905 Bytes (for vanilla)
- compressed zip of non-Brotlis: 374628 Bytes (for cookbook)
- uncompressed zip of Brotlis: 147665 Bytes (for zip of Brotlis)
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:
- OS: macOS Monterey 12.0.1 (21A559)
- Processor: 2,6 GHz 6-Core Intel Core i7
- Memory: 32 GB 2667 MHz DDR4
Throttle network
To emulate real world network conditions, I used throttle with the following profiles:
- 3gslow: down: 400kbit/s up: 400kbit/s rtt: 200ms
- 3gfast: down: 1600kbit/s up: 768kbit/s rtt: 75ms
- 4g: down: 9000kbit/s up: 9000kbit/s rtt: 85ms
- 50k: down: 50000kbit/s up: 30000kbit/s rtt: 5ms
- 100k: down: 100000kbit/s up: 30000kbit/s rtt: 5ms
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:
- Vanilla: just download all assets individually
- Zipped Brotli: download + unzip + decompress
- Cookbook: download + unzip
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.