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/).
  • brotli and zstd command-line tools (Ubuntu: apt install brotli zstd), or Node's built-in zlib (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.

Compressed size of one HTML page by algorithm and level Horizontal bars of compressed size for a 58 kilobyte documentation page. gzip level 6, 12.9 kilobytes. Brotli level 4, 11.8 kilobytes. zstd level 3, 12.4 kilobytes. zstd level 19, 10.6 kilobytes. Brotli level 11, 9.9 kilobytes. One 58 KB HTML page at different settings gzip 6 (on the fly) 12.9 KB zstd 3 (on the fly) 12.4 KB brotli 4 (on the fly) 11.8 KB zstd 19 (precompressed) 10.6 KB brotli 11 (precompressed) 9.9 KB
A representative Eleventy docs page; the on-the-fly defaults sit at the top, the precompressed settings at the bottom.

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.

Precompressed file support by host The root asks which host serves the site. Caddy serves zstd, brotli and gzip variants natively with one option. nginx serves brotli and gzip variants with brotli_static and gzip_static. Cloudflare Workers compress at the edge unless a small Worker maps Accept-Encoding to the precompressed asset. S3 with CloudFront compresses automatically and precompressed variants are rarely worth the effort. Can your host serve precompressed files? Which host serves the files? Caddy Native precompressed zstd br gzip nginx Module brotli_static + gzip_static Cloudflare Workers Small Worker encodeBody manual S3 + CloudFront Usually skip edge compresses already
The saving is the same everywhere; what differs is how much configuration it costs to deliver it.

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.

Bytes transferred per session with different compression Vertical bars of compressed HTML, CSS and JavaScript transferred in an average session of 4.3 pages on the Eleventy site. gzip on the fly 71 kilobytes. Brotli on the fly at level 4, 64 kilobytes. Brotli 11 precompressed, 54 kilobytes. zstd 19 precompressed, 57 kilobytes. Total transfer for a 4.3-page session 71 KB gzip 6 on the fly 64 KB brotli 4 on the fly 57 KB zstd 19 precompressed 54 KB brotli 11 precompressed
Computed from each page's served size under each configuration and the average session path from analytics.

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

MeasureCDN on-the-fly (brotli 4)Precompressed (brotli 11)
Eleventy: all HTML, compressed10.9 MB9.2 MB (−16%)
Astro: CSS + JS on first load57 KB48 KB (−16%)
Eleventy: median docs page transfer11.8 KB9.9 KB
LCP p75, field (Eleventy, Caddy origin)1.24 s1.21 s
Build time added021 s (Eleventy), 7 s (Astro)
Monthly egress, Eleventy site412 GB351 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 .br files to hosts that serve them as downloads. Some hosts will expose page.html.br at 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.

Filed underHow-tosEleventyCloudflarenginxCaddy