Blog ·
Brotli beat zstd on our JavaScript. Then zstd won by 130×.
We expected the modern compressor to win. On a 23 KB script it didn't, until we gave it something Brotli already had.
Our monitoring script is about 23 KB of minified JavaScript. Every byte of it is downloaded by visitors to our customers’ sites, so we compress each customer’s copy at publish time, at maximum settings, in every format browsers support. Then each browser gets the smallest one it can decode.
We assumed zstd would win. It’s the newer algorithm, and it’s excellent. Here’s what we actually measured:
| Format | Size |
|---|---|
| gzip, level 9 | 9,022 B |
| zstd, level 19 (no gain at level 22) | 8,789 B |
| Brotli, quality 11 | 8,203 B |
Brotli is about 7% smaller. Why?
Brotli shows up with a dictionary
Brotli ships with a built-in static dictionary of around 120 KB of common web text: HTML tags, CSS properties, JavaScript keywords, common English words. Any match against that dictionary can be referenced instead of sent. On a large file it hardly matters, but on a 23 KB file it’s a big head start.
zstd has no built-in dictionary. It’s designed to be fast and to do well on big data, where a dictionary matters less. So we tried giving it one: we used the source of the web-vitals library, which our script bundles, as a raw dictionary.
| Format | Size |
|---|---|
| zstd -19 with a dictionary | 7,121 B |
Now zstd wins by 13%. But a dictionary only helps if the browser already has it, and on a first visit it doesn’t.
Except returning visitors do have one
Compression Dictionary Transport (RFC 9842) lets a browser use a file it already has cached as the dictionary for the next version of that file. The browser tells the server which version it has (Available-Dictionary: :<sha-256>:). If we’ve prepared a delta from that version, we send it with Content-Encoding: dcz, and the browser rebuilds the new file locally.
For our script, the dictionary is the previous version of the script itself. When a customer changes a setting, the new script differs by a few bytes. We measured this end to end in Chromium:
| Update | Full download (Brotli) | dcz delta |
|---|---|---|
| A customer changes their sample rate | 8,413 B | 65 B |
| We ship a new diagnostics chunk | 1,326 B | 192–399 B |
The 65 bytes include the RFC’s fixed 40-byte header (8 magic bytes plus the SHA-256 of the dictionary), so the actual change costs about 25 bytes of zstd data.
Three things that caught us out
1. crossorigin="anonymous" is required. A third-party <script> without it is fetched in no-cors mode, and its response is opaque. Chromium won’t use an opaque response as a dictionary, which makes sense, since the page isn’t allowed to read it. With the attribute, the browser sends Available-Dictionary and accepts dcz. Our install snippet includes it now, and our test suite checks both cases.
2. Dictionary lifetime follows HTTP caching. A cached dictionary is only usable while it’s fresh or within its stale-while-revalidate window (RFC 9842 §2.2.1). We want settings changes to reach visitors within minutes, and dictionaries that last. So the script is served with max-age=300, stale-while-revalidate=604800: it’s fresh for five minutes and usable as a dictionary for a week.
3. CloudFront doesn’t speak zstd, let alone dcz. Its built-in compression covers gzip and Brotli only, and it drops zstd from the cache key. So we pre-compute every variant and every delta, store them as separate objects, and use a small CloudFront Function to rewrite each request to the right one. A CloudFront KeyValueStore tells the function which deltas exist. Since the rewritten URL is the cache key, every variant caches normally.
The takeaway
Measure before you pick a compressor, especially for small files. And if your visitors come back, the biggest win isn’t a better general-purpose compressor. It’s sending only what changed.