Precompressing Assets with Brotli and Zstd
Every modern host compresses text responses, so compression feels like a solved problem. It is solved at a compromise: a CDN compressing on the fly has to finish within a few milliseconds per response, so it uses moderate levels — gzip 6, brotli 4 or 5 — that leave a surprising amount on the table. A static site knows all its responses at build time, which means it can afford the slowest, strongest settings once and serve the result forever.
This guide precompresses the output of a static build with brotli at level 11 and zstd at level 19, configures Caddy, nginx and Cloudflare to serve the variants, and measures the saving on an Eleventy documentation site (1,300 pages) and an Astro site (410 pages). It is part of CSS and JavaScript Asset Optimization.
Prerequisites
- A static build directory (
dist/or_site/). brotliandzstdcommand-line tools (Ubuntu:apt install brotli zstd), or Node's built-inzlib(brotli) and a zstd binding.- A host that can serve precompressed files: Caddy, nginx with the brotli module, Cloudflare Workers static assets, or a CDN in front of object storage with content-encoding metadata.
Step 1: Compress the Build Output
A post-build step compresses every text file above 1 KB. Parallelism matters on large sites:
#!/usr/bin/env bash
# scripts/precompress.sh
set -euo pipefail
find dist -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.mjs' \
-o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) -size +1k -print0 \
| xargs -0 -P "$(nproc)" -n 32 sh -c '
for f; do
brotli -q 11 -w 24 -k -f "$f"
zstd -19 -q -k -f "$f"
done' _
On an 8-vCPU CI runner, this took 21 s for the Eleventy site's 1,300 pages plus assets (about 4,100 files) and 7 s for the Astro site. Level 11 brotli is slow per file — about 30× slower than level 4 — but parallel over files it stays well inside a typical build budget.
If the build runs in Node already, the same work can be done with zlib.brotliCompress and BROTLI_PARAM_QUALITY: 11; the output is identical.
Step 2: Serve the Variants
The server must pick the best variant the browser accepts, set Content-Encoding, keep the original Content-Type, and add Vary: Accept-Encoding.
Caddy does it with one option, as in Serving a Static Site with Caddy:
file_server {
precompressed zstd br gzip
}
nginx needs gzip_static (built in) and brotli_static from the ngx_brotli module; zstd static serving needs a third-party module, so most nginx setups serve brotli and gzip only:
brotli_static on;
gzip_static on;
Cloudflare Workers static assets do not read .br siblings; Cloudflare compresses at the edge itself. To serve your own precompressed bytes there, a Worker can map Accept-Encoding to the .br asset and set Content-Encoding with encodeBody: 'manual' on the response. On this setup the Worker added about 1 ms and delivered the level-11 bytes; for sites where that complexity is unwelcome, Cloudflare's own brotli is a reasonable default.
S3 + CloudFront: upload the brotli variant as the object itself with Content-Encoding: br metadata only if every client supports brotli — which is not safe for all clients. More commonly, let CloudFront compress (it uses brotli for supporting clients) and accept its level.
Step 3: Verify What Is Served
Check each encoding explicitly with curl:
for enc in zstd br gzip identity; do
printf '%-9s' "$enc"
curl -s -o /dev/null -H "Accept-Encoding: $enc" \
-w '%{http_code} %{size_download} bytes %header{content-encoding}\n' https://docs.example.com/guide/install/
done
# zstd 200 10612 bytes zstd
# br 200 9904 bytes br
# gzip 200 12877 bytes gzip
# identity 200 58213 bytes
Also confirm Vary: Accept-Encoding is present, or a CDN in front of the origin may cache a brotli response and serve it to a client that cannot decode it.
Step 4: Keep Variants in Sync
A precompressed variant is a copy of the file. If a later build step changes an HTML file — a CSP hash injection, a sitemap update, a search indexer rewriting pages — after compression has run, browsers accepting brotli get the stale version and others get the new one. The bug is maddening because it depends on the browser. Run precompression as the very last step, and add a check that each .br decompresses to exactly the original:
find dist -name '*.br' | head -200 | while read -r f; do
cmp -s <(brotli -d -c "$f") "${f%.br}" || { echo "stale: $f"; exit 1; }
done
Deciding Between Brotli and Zstd
For static text, brotli at level 11 beat zstd at level 19 on size by 5–8% on both sites, largely because brotli ships a built-in dictionary of common web strings. Zstd decompresses faster, which matters little for pages of this size. Serving both lets browsers that prefer zstd use it, but the practical benefit over brotli-only was nil in field data. If a host makes zstd awkward (as nginx does), serve brotli and gzip and move on.
Compression dictionaries (Compression-Dictionary-Transport) are the next step: a site can tell supporting browsers to use a previous version of a file as a dictionary for the next, which shrinks updated JavaScript bundles dramatically. Chrome supports it since version 130; it needs server cooperation beyond static file serving and is worth watching rather than adopting blindly.
Caching the Compression Step
On large sites the compression step can be made incremental. Most files are unchanged between builds, especially hashed CSS and JavaScript, so there is no need to recompress them. Keep a cache directory keyed by the SHA-256 of each file's content: before compressing, look up the hash; if a .br and .zst for that hash exist, copy them instead. Persist the cache between CI runs with actions/cache, as with other build caches in Sharing Build Cache Across CI Runners. On the Eleventy site a typical content change touched about 30 HTML files, and the cached step took 2 s instead of 21 s.
Measured Impact
| Measure | CDN on-the-fly (brotli 4) | Precompressed (brotli 11) |
|---|---|---|
| Eleventy: all HTML, compressed | 10.9 MB | 9.2 MB (−16%) |
| Astro: CSS + JS on first load | 57 KB | 48 KB (−16%) |
| Eleventy: median docs page transfer | 11.8 KB | 9.9 KB |
| LCP p75, field (Eleventy, Caddy origin) | 1.24 s | 1.21 s |
| Build time added | 0 | 21 s (Eleventy), 7 s (Astro) |
| Monthly egress, Eleventy site | 412 GB | 351 GB |
The LCP change is small because these pages were already light; the egress saving of 15% is the more tangible result on a self-hosted origin billed by transfer.
Pitfalls & Rollback
- Precompressing images and fonts. WOFF2, JPEG, WebP and AVIF are already compressed; skip them.
- Missing
Vary: Accept-Encoding. Caches can serve an encoding the client cannot read. - Stale variants after late build steps. Compress last and verify.
- Uploading
.brfiles to hosts that serve them as downloads. Some hosts will exposepage.html.brat its own URL with the wrong content type; exclude the variants from deploy where the host does not use them. - Rollback: delete the variants or disable the server option; the host falls back to on-the-fly compression immediately.
Conclusion
Precompression turns compression from a per-request compromise into a build-time maximum. Brotli 11 cut text transfer by about 16% compared with on-the-fly brotli on both sites, for 7–21 seconds of build time. It pays off most on self-hosted origins and hosts that serve variants natively; on platforms that compress at the edge themselves, weigh the saving against the extra configuration.
FAQ
Why precompress if the CDN already compresses responses?
CDNs compress on the fly at moderate levels to keep latency low, around brotli level 4 or gzip level 6. Precompressing at brotli level 11 takes seconds per build instead of milliseconds per request and produces files roughly 10 to 20 percent smaller, at no request-time cost.
Which browsers support zstd content encoding?
Chrome and Edge support zstd since version 123 and Firefox since version 126. Safari did not advertise zstd at the time of measurement. Serving brotli to browsers that do not accept zstd keeps everyone covered.
Which files are worth precompressing?
Text formats, including HTML, CSS, JavaScript, SVG, JSON, XML and plain text, that are larger than about 1 KB. Images such as JPEG, WebP and AVIF, fonts in WOFF2, and already compressed archives gain nothing and should be skipped.
What happens if the precompressed file and the original differ?
The server serves whichever variant the browser accepts, so a stale .br file next to an updated original sends old content to some browsers only. Always regenerate every variant in the same build step and deploy them together.
Related
- Parent: CSS and JavaScript Asset Optimization — the full asset pipeline.
- Serving a Static Site with Nginx — brotli_static in a full server block.
- Minifying HTML, CSS and JS in Eleventy — the step before compression.
- Improving TTFB for Static HTML — the other half of fast HTML delivery.
- Setting Cache-Control Headers on Cloudflare Pages — caching the compressed responses.
Filed underHow-tosEleventyCloudflarenginxCaddy