Gzip vs Brotli: which compression to use
Gzip vs Brotli: learn which compression to use, with practical examples, common mistakes, and what to watch for when applying it in production.
I turned Brotli up to max and took the server down
I saw a benchmark saying Brotli at level 11 compresses much better than Gzip, figured it was just flipping a flag, and went for it. I turned on brotli on with quality 11 for all the dynamic traffic of an API. The result: p95 latency doubled and the server CPU pinned to the ceiling under load. The byte savings were real, but I was paying for them in CPU on every request, on content I couldn't even cache. The lesson wasn't "Brotli is bad" — it was that compression is a tradeoff between CPU and bandwidth, and that tradeoff changes depending on whether the content is static or dynamic.
Here I compare Gzip and Brotli from an operator's point of view, not from someone who just read a benchmark. Since compression and caching solve neighboring problems — one shrinks what you transfer, the other avoids transferring it again — it's worth reading alongside the post on browser caching with Cache-Control and ETag.
How HTTP compression works, fast
The mechanism is a negotiation. The client sends a header on the request saying what it accepts:
Accept-Encoding: gzip, br
The server picks an algorithm it understands, compresses the response, and returns it announcing what it used:
Content-Encoding: br
Vary: Accept-Encoding
br is Brotli's identifier; gzip is Gzip's. The Vary: Accept-Encoding header is important and frequently forgotten: it tells caches (CDN, proxy) that the response varies by encoding, so they don't serve a Brotli body to a client that only understands Gzip. Forgetting it hands unreadable bytes to a chunk of your users.
The real difference between the two
Gzip has been around since the 90s, is universal, and is fast. Brotli is newer (Google, ~2015), was designed for the web, and compresses fewer bytes than Gzip on text — HTML, CSS, JS, JSON. The typical difference is around 15% to 25% smaller for web assets at the max level.
The detail that changes everything is the cost curve. Brotli has 12 quality levels (0 to 11). Level 11 compresses the most, but it's dramatically slower than Gzip at the equivalent level — it can be an order of magnitude more expensive in CPU. Brotli's low levels, on the other hand, are fast and compress similarly to fast Gzip. In other words: "Brotli vs Gzip" isn't a single question. It's "high-level Brotli vs Gzip" and "low-level Brotli vs Gzip", and the answers are opposite.
Static vs dynamic: the decision that matters
This is the split that avoids the mistake I made. Content divides into two worlds, and each one calls for a different strategy.
Static is everything you generate once at build time and serve many times unchanged: the JS bundle, the CSS, text-format fonts. For this the strategy is precompression. You compress at the max level (Brotli 11) during the build, save the .br next to the file, and the server just delivers the ready-made file. The CPU cost is paid once, offline. Runtime latency is zero. It's the best of both worlds and there's no excuse not to do it.
# nginx serving precompressed files without compressing at runtime
brotli_static on;
gzip_static on;
Dynamic is what changes on every request: an API's JSON response, per-user rendered HTML. Here you can't precompress — you have to compress on the spot, inside the request window. The strategy is the opposite: low level. Brotli 4 or 5, or Gzip 5 to 6. You give up a few bytes to avoid blowing up the CPU under load. That's exactly where I went wrong: I used level 11 on dynamic content.
Don't compress what's already compressed
A silent and common mistake. Images (JPEG, PNG, WebP), video (MP4), audio, .zip, .gz files — all are already compressed in their own format. Running Gzip or Brotli over them doesn't reduce size (sometimes it adds a few bytes) and wastes CPU for nothing.
The rule is to compress only text-based content. Configure it by MIME type:
# compress text; leave compressed binaries alone
gzip_types text/plain text/css application/json
application/javascript text/xml application/xml
image/svg+xml;
SVG is included because it's text (XML). WebP and AVIF stay out because they're already compressed binaries. Telling the server to compress */* is an anti-pattern that costs CPU with no return.
What to actually configure
Putting it together, the setup I recommend for most projects:
- Static assets: precompressed at build, Brotli 11 and Gzip 9 side by side, served with
brotli_static/gzip_static. Zero runtime cost, maximum bandwidth savings. - Dynamic responses: runtime compression with Brotli at level 4–5 (or Gzip 5–6), only for text types. Balance between CPU and bandwidth.
- CDN in front: if you use Cloudflare, Fastly, or similar, much of this is handled at the edge — they negotiate encoding, cache by
Vary, and often precompress. Check what your CDN already does before duplicating the work at the origin. - Minimum threshold: don't compress tiny responses. Below ~1KB the encoding overhead isn't worth it; most servers have a
gzip_min_lengthfor this.
The CPU saved on dynamic content is usually worth more than the extra bytes — bandwidth is cheap, latency under load isn't. On static it's the reverse: compress without mercy, because the cost was already paid at build time.
Frequently asked questions
Is Brotli always better than Gzip?
In compression density for text, yes, at the same effort level. But "better" depends on the CPU cost. High-level Brotli is better for precompressed static; for dynamic content, low-level Brotli or even Gzip can be the right choice because it compresses fast. There's no single winner — it depends on static vs dynamic.
Do I need to disable Gzip if I enable Brotli?
No. Keep both. Not every client understands Brotli (old clients, some proxies), and Accept-Encoding lets the client choose. The server delivers Brotli to those who accept it and Gzip as a fallback. Serving both is the correct behavior.
Why doesn't compressing images help?
Because JPEG, PNG, WebP, and the like already apply image-specific compression internally. Gzip and Brotli are built for text and find no new redundancy in already-compressed data. The result is practically the same size with CPU wasted — sometimes the file even grows a few bytes because of the header.
Which Brotli level should I use for dynamic content?
Something between 4 and 5 in practice. That's where the compression/CPU ratio stays healthy for compressing within the request window without punishing latency. Level 11 is reserved for offline precompression of static assets, where CPU time doesn't count against the user.
What to take from this
Compression isn't a flag you flip and forget — it's a deliberate tradeoff between CPU and bandwidth, and the right answer depends on whether the content is static or dynamic. For static: precompress at the max level during the build and serve the ready file. For dynamic: low level, compressing on the fly, text only. Don't compress already-compressed binaries, don't forget Vary: Accept-Encoding, and keep Gzip and Brotli coexisting to cover all clients. The mistake that cost me a doubled p95 was treating everything as if it were the same thing.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.