←All posts

Zstandard Content-Encoding Goes Cross-Browser: Where zstd Actually Beats Brotli

With Safari 26.3 filling the last gap, zstd content-encoding now reaches roughly 84% of global traffic. Here is where it wins over Brotli, where it doesn't, and how to deploy it without breaking your cache.

Zstandard Content-Encoding Goes Cross-Browser: Where zstd Actually Beats Brotli

For most of the last decade, HTTP compression on the web has been a two-horse race. Gzip for everything, Brotli when you could get it. Zstandard — Facebook's compression algorithm, standardized as RFC 8878 — has been shipping in Chrome since version 123 and Firefox since 126, but it sat in that awkward category of "supported by most browsers, so you still have to serve the fallback anyway."

That changed quietly this year. Apple added Zstandard support in Safari 26.3 when running on OS 26.3 or later, filing an issue against MDN's browser-compat-data in January to get the tables updated. caniuse now puts zstd at roughly 84% of global traffic — 81.7% full support plus another 2.2% partial, based on StatCounter data for July 2026.

That is enough to matter. But "you can turn it on" and "you should turn it on" are different questions, and the honest answer depends entirely on what you're serving.

The tradeoff in one sentence

Brotli compresses smaller. Zstandard compresses faster.

That's the whole story, and it's not a small distinction. Brotli at high quality levels produces meaningfully smaller payloads than zstd at comparable settings — it ships a shared dictionary tuned for web text, and its higher levels do genuinely more work. If your only metric is bytes over the wire, Brotli wins.

The catch is what that work costs. As Paul Calvano's compression comparison has documented, Brotli's top quality levels are dramatically more expensive to encode than its defaults — expensive enough that running them on every dynamic response is not a serious option. That's why nearly everyone runs Brotli at level 4–5 for dynamic content and reserves levels 9–11 for build-time compression of static assets.

Once you're comparing fast Brotli against fast zstd, the margin narrows considerably, and zstd's speed advantage starts to show up somewhere you actually feel it: time to first byte.

Where zstd wins: dynamic responses

Here's the mental model that makes the decision easy.

Static assets you compress at build time. Your JavaScript bundles, CSS, fonts, and prerendered HTML get compressed once, during CI, and served from disk thousands of times. Compression CPU is a one-time cost you've already paid. Use Brotli at maximum quality. There is no argument for zstd here — you're optimizing purely for bytes, and Brotli delivers.

Dynamic responses you compress per request. API payloads, server-rendered pages with per-user content, streamed HTML, large JSON responses. Every byte costs CPU at request time, in the critical path, while the client waits. This is where zstd earns its place: it reaches a comparable ratio in less time, which means the origin starts flushing bytes sooner and your compression tier stops being a throughput ceiling under load.

For a Next.js app like the one this site runs on, that split is clean. The static export in out/ gets Brotli-max at build time. Anything coming out of a Worker or a Pages Function — a contact form response, a search endpoint, a personalized fragment — is a zstd candidate.

Deploying it without breaking anything

Content negotiation does the safety work for you. The client advertises what it can decode; the server picks from that list. A browser that has never heard of zstd simply won't list it, and your existing Brotli or gzip path serves it exactly as before.

GET /api/search?q=widget HTTP/2
Accept-Encoding: gzip, deflate, br, zstd
HTTP/2 200
Content-Type: application/json
Content-Encoding: zstd
Vary: Accept-Encoding

The Vary: Accept-Encoding header is not optional. Without it, any shared cache in front of your origin — CDN, reverse proxy, corporate middlebox — is free to hand a zstd-encoded body to a client that only asked for gzip. This has always been true for Brotli; adding a third encoding just adds a third way to get it wrong.

On nginx, zstd isn't built in. You need a third-party module — zstd-nginx-module is the common choice, and it ships both an on-the-fly filter and a static variant:

# On-the-fly compression for dynamic responses
zstd on;
zstd_comp_level 5;
zstd_min_length 1024;
zstd_types text/plain text/css application/json application/javascript
           text/xml application/xml image/svg+xml;

# Serve precompressed .zst files where they exist
zstd_static on;

zstd_static on declines the request unchanged when no .zst file exists, so gzip_static and the regular gzip filter still apply normally behind it. That means you can add zstd to an existing config without rewriting your fallback chain.

Level 5 is a reasonable starting point for dynamic content. Level 3 is zstd's own default and is genuinely fast; anything above about 9 starts trading away the speed advantage that made you pick zstd in the first place. Measure before you climb.

Precompressing at build time

If you do want zstd for static files — worth it when you're serving a mixed client population and want to avoid decompressing-and-recompressing at the edge — generate the artifacts in CI alongside your Brotli ones:

# Compress every text asset in the export, keeping originals
find out -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \
  -o -name '*.svg' -o -name '*.json' \) -print0 \
  | xargs -0 -P "$(nproc)" -I{} sh -c 'zstd -19 -q -f -k "{}"'

Level 19 is fine here — you're paying it once in CI, not per request. Note this produces .zst files in addition to the originals (-k keeps the source), which is what zstd_static expects.

Verifying it actually works

Do not trust your config. Check the wire.

curl -s -o /dev/null -D - https://example.com/api/data \
  -H 'Accept-Encoding: zstd' \
  | grep -i 'content-encoding\|vary'

You want to see content-encoding: zstd and vary: Accept-Encoding. Then run the same request without the header to confirm the fallback still fires:

curl -s -o /dev/null -D - https://example.com/api/data \
  -H 'Accept-Encoding: gzip' \
  | grep -i 'content-encoding'

If the second request also comes back zstd, you have a server that's ignoring negotiation, and you will break older clients. Fix that before you ship.

Worth checking the same two requests through your CDN as well as against the origin directly — a correct origin behind a misconfigured cache still produces broken responses, and the failure mode is intermittent and user-specific, which makes it miserable to debug from support tickets.

The compatibility caveat that's easy to miss

caniuse marks desktop Safari as partial support even at version 27, while iOS Safari 26.3 and later is marked full. The practical implication is that you cannot treat "Safari 26+" as a single bucket, and you shouldn't be writing user-agent-based logic here anyway. Content negotiation handles this correctly and automatically. Every browser that lists zstd in Accept-Encoding can decode it; every browser that doesn't, won't be sent it.

This is the argument for never shortcutting Accept-Encoding with a UA sniff, even when it seems easier. The header is the source of truth, and it's the only thing that stays correct as the compatibility table shifts under you.

What to actually do

If you're deciding where to spend an afternoon:

  • Audit what you're compressing per request. If your server-rendered HTML or JSON APIs are running Brotli on the fly, that's the highest-value place to test zstd. Compare TTFB under realistic concurrency, not on an idle box.
  • Leave your static pipeline alone. Brotli-max at build time is still correct for bundles and prerendered pages. Adding zstd there is optional and mostly helps origins with unusual client mixes.
  • Add Vary: Accept-Encoding everywhere, today. This is worth doing regardless of whether you adopt zstd. It's the single most common cause of encoding-related cache bugs.
  • Verify with curl against both origin and CDN. Two requests, thirty seconds, catches the failure mode that's hardest to find later.

The interesting shift is that compression is no longer a one-size decision. Build-time and request-time have genuinely different cost structures, and now there's an algorithm well-suited to each. Treating them as one setting is leaving performance on the table in one direction or CPU on the table in the other.


Sources: caniuse: zstd content-encoding · mdn/browser-compat-data #28948 · RFC 8878 · zstd-nginx-module · Paul Calvano on gzip vs Brotli vs Zstandard

←Back to all posts
Next step

Need help shipping software?

Tell us what you're trying to build. A discovery call, a one-page summary within 48 hours, a proposal within a week.

Response · 48h·NDA on request·US contracts only