12k
All articles

Gzip vs Brotli vs Zstd for Compressing Web Assets

Brotli vs gzip vs zstd for web assets: when to use each, how compression levels affect size, and how to precompress static files.

OpenReplay Team
OpenReplay Team
Gzip vs Brotli vs Zstd for Compressing Web Assets

Use Brotli for static text assets, gzip as the universal fallback, and zstd for dynamic responses where you control both the server and the CDN.

Plenty of bundles still ship gzipped because a build tool or a hosting default picked that years ago, and nobody has looked at the algorithm or the level since. The rest of this article covers why static and dynamic content are different problems, why the compression level often changes the outcome more than the algorithm name does, how to precompress at build time, how the browser and server negotiate, and what to leave uncompressed.

Key Takeaways

  • Brotli at its maximum level is the right choice for static JavaScript and CSS because the cost of slow compression is paid once at build time, not per request.
  • Zstd suits dynamic responses: Cloudflare measured it compressing 42% faster than Brotli while landing close to it on size, with averages of 2.56:1 for gzip, 2.86:1 for zstd and 3.08:1 for Brotli.
  • The level dial matters as much as the algorithm name: switching to Brotli while leaving a fast default level in place gives up part of the saving.
  • nginx’s gzip_comp_level defaults to 1 and ngx_brotli’s brotli_comp_level defaults to 6; neither default is the maximum.
  • Encoding negotiation happens entirely in Accept-Encoding and Content-Encoding headers, so switching algorithms is a server or CDN setting, not an application code change.

Brotli vs Gzip vs Zstd: The Recommendation

Brotli is the right default for static JavaScript and CSS because compression happens once at build time, so the slow top levels cost nothing at request time.

Gzip stays in the stack as the fallback because every browser lists it in Accept-Encoding; it is not the best choice for anything, but it is the one choice that never fails.

Zstd belongs on dynamic responses, where compression cost is paid on every request, because it reaches a ratio close to Brotli’s in a fraction of the CPU time.

Static vs Dynamic: When Is the Compression Cost Paid?

The decision between algorithms is a decision about when the CPU cost is paid. A static bundle is compressed once per release and served thousands of times, so only the ratio matters and compression speed is irrelevant. An HTML page rendered per request is compressed on every hit, so compression speed becomes a latency and server-cost concern on par with the ratio.

Cloudflare’s September 2024 announcement of Zstandard support supplies the numbers for the dynamic case. In a Q3 2024 test that switched Free-plan traffic from Brotli to zstd for 24 hours, with zstd at its default level 3 and the Brotli and gzip levels not stated, zstd compressed 42% faster than Brotli and came close to matching it on size. The measured averages were 2.56:1 for gzip, 2.86:1 for zstd and 3.08:1 for Brotli, and Cloudflare’s own conclusion was that zstd is the better fit for dynamic responses, HTML included.

Verdict: Brotli wins where ratio is the only criterion, which describes every static asset. Zstd wins where the compression runs per request.

Compression Level Matters as Much as the Algorithm

Every algorithm exposes a level dial, and the level often moves the result more than switching algorithms does. Brotli runs levels 0 to 11 in ngx_brotli, gzip runs 1 to 9 in nginx, and the zstd CLI runs 1 to 19 with a default of 3, plus 20 to 22 behind --ultra.

How far the dial moves the result depends entirely on the input, so published before-and-after figures for someone else’s bundle tell you very little about your own. Run your files through the compression comparison tool, which compresses with gzip, Brotli and zstd at any level in the browser, and compare the sizes yourself.

The defaults explain why so many sites ship at the fast end. nginx’s gzip_comp_level defaults to 1, the fastest of levels 1 to 9. ngx_brotli’s brotli_comp_level defaults to 6 of 0 to 11. Neither is the maximum, and both are sensible for on-the-fly compression, which is exactly the wrong context for static assets. Whatever level you pick is paid on the compressing side. The zstd project’s own README makes the point that decoding runs at about the same speed whichever level produced the file, which holds for zlib and lzma as well.

Verdict: set the level deliberately. Maximum for anything precompressed; a middle level for anything compressed per request.

How Do You Precompress Assets at Build Time?

Precompression means the build step writes a .br, .gz and optionally .zst sibling next to each asset, and the server picks the matching file without compressing anything at request time.

brotli -q 11 app.js    # writes app.js.br; source kept by default
gzip -9 -k app.js      # writes app.js.gz; -k keeps the source
zstd -19 app.js        # writes app.js.zst; source kept by default

The brotli CLI preserves inputs by default and accepts -q for quality 0 to 11. GNU gzip needs -k or --keep to leave the original in place.

Serving the siblings in nginx takes two directives:

load_module modules/ngx_http_brotli_static_module.so;

http {
    gzip_static   on;   # serves .gz siblings; module needs --with-http_gzip_static_module
    brotli_static on;   # serves .br siblings; default off
    gzip_vary     on;   # adds Vary: Accept-Encoding; default off
}

ngx_http_gzip_static_module is not built by default. brotli_static comes from ngx_brotli. nginx ships no zstd module of its own; the third-party zstd-nginx-module adds a zstd_static directive for .zst siblings, off by default, and without it zstd for static files is a CDN-side setting.

CDNs will pass precompressed files through. Cloudflare’s compression documentation states it keeps an origin’s content-encoding: br or gzip when the visitor’s browser supports it and no response-rewriting features (Rocket Loader, Email Address Obfuscation, Polish and others) are enabled; it requests from origins with accept-encoding: br, gzip, so origin zstd is not passed through. Akamai’s Brotli Support behavior serves and caches origin-compressed Brotli, returns non-Brotli variants to clients that do not accept br, and does no edge compression itself.

Verdict: compress at build time, at maximum level, and configure the server or CDN to serve the sibling.

How Do the Browser and Server Agree on an Encoding?

The browser lists the encodings it can decode in Accept-Encoding, the server or CDN picks one and labels the response with Content-Encoding, and no application code participates in the exchange.

GET /app.3f2a1b.js HTTP/1.1
Accept-Encoding: gzip, deflate, br, zstd
HTTP/1.1 200 OK
Content-Encoding: br
Vary: Accept-Encoding

br is Brotli’s token in the HTTP content-coding registry, defined in RFC 7932 section 13. Vary: Accept-Encoding tells shared caches not to hand a Brotli body to a client that only asked for gzip; nginx omits it unless gzip_vary on is set.

To see what a site ships today, request one bundle file and read the single header that comes back:

curl -sI -H 'Accept-Encoding: gzip, br, zstd' https://example.com/app.js | grep -i content-encoding

The same information appears in the DevTools Network panel under response headers.

Browser support decides the fallback order. Brotli is supported by every current major browser. caniuse lists zstd in Chrome and Edge from 123, Firefox from 126, Opera from 109, and Safari from 26, with desktop Safari marked as partial support. Any browser that omits zstd from Accept-Encoding is simply served Brotli or gzip. There is no failure mode, only a fallback.

Verdict: switching algorithms is a configuration change, and the fallback chain makes it safe.

What Should You Not Compress?

Recompressing JPEG, MP4 or WOFF2 wastes CPU on both ends for a saving of roughly nothing, because those formats are already compressed internally. WOFF2 is the clearest case: RFC 7932 section 1.2 records that the format it defines is built into WOFF 2.0, so a .woff2 file is already Brotli output.

Very small responses are the other exclusion. Below a few dozen bytes the encoding overhead exceeds the saving, which is why nginx’s gzip_min_length and ngx_brotli’s brotli_min_length default to 20 bytes, and Cloudflare compresses only responses of at least 48 bytes for gzip and 50 for Brotli and zstd.

The conclusions here apply to the files browsers actually receive, from a few kilobytes to a few megabytes. Benchmarks showing Brotli taking minutes come from multi-hundred-megabyte files that never travel through Content-Encoding.

Verdict: compress text (HTML, CSS, JavaScript, JSON, SVG); skip media, fonts and tiny responses.

Conclusion

The algorithm question has a settled answer: Brotli at maximum level for anything compressed at build time, zstd for anything compressed per request, gzip as the floor that every client understands. The level question is the one most stacks have never asked. Run the curl check against your own bundle, note the encoding and infer the level from the size, and if the answer is gzip at a fast default, precompressing with Brotli is a build step and two server directives away.

FAQs

Why does my site still send gzip when the browser supports Brotli?

Three causes account for almost every case. First, Chrome and Firefox only advertise 'br' in Accept-Encoding over HTTPS, so plain HTTP requests, including most localhost development, fall back to gzip. Second, the server has no Brotli module loaded; nginx needs ngx_brotli, which is not built in. Third, a CDN such as Cloudflare passes through an origin response already labelled content-encoding: gzip rather than transcoding it to Brotli.

What is the difference between 'deflate' and 'gzip' in Content-Encoding?

Both carry the same DEFLATE algorithm from RFC 1951 and differ only in the wrapper. In HTTP, 'deflate' means the zlib format from RFC 1950 (two-byte header, Adler-32 checksum) and 'gzip' means the RFC 1952 container with a CRC-32 trailer. Early servers and browsers sometimes sent raw DEFLATE under the 'deflate' name, forcing clients to guess, so gzip became the reliable choice and deflate is rarely worth offering.

Does Node.js support Brotli and zstd compression natively?

Yes. The built-in node:zlib module implements the gzip, deflate, br and zstd content-encodings without third-party packages. Brotli has shipped since Node.js 11.7.0 through zlib.brotliCompress and zlib.createBrotliCompress; Zstandard arrived in Node.js 23.8.0 with zlib.zstdCompress and zlib.createZstdCompress, and Node.js 24.6.0 added dictionary support to the zstd APIs (https://nodejs.org/en/blog/release/v24.6.0). Compression middleware that predates these releases may still negotiate only gzip, so check the content-encoding it emits.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.