Skip to main content
HTTP

Gzip, Brotli, and Zstd: HTTP compression without accidental identity encoding

Compress text responses at the edge, negotiate with Accept-Encoding, and leave already-compressed images and archives alone. Brotli is often best for static assets; gzip remains universal.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 3 min read

Lighthouse keeps flagging "Enable text compression" on an API that already sits behind a CDN. You check the CDN — compression is on. You check the browser — Accept-Encoding: gzip, br goes out fine. But the response comes back Content-Encoding: identity, and the CDN won't touch a response the origin has already declared final. The framework was serving JSON uncompressed and labeling it "identity" — the encoding that means I chose not to compress this. Nothing was broken. Nobody had turned compression on.

That's the whole game with HTTP compression: it's a negotiation between the client's Accept-Encoding and the server's Content-Encoding, and both ends have to actually opt in. Get the negotiation right and a 200 KB JSON payload ships as 20–30 KB. Get it wrong and you either send bytes nobody asked for, or you burn CPU compressing things that can't shrink.

Common mistakes

Compressing what's already compressed

JPEG, PNG, WebP, MP4, and ZIP are already entropy-coded. Running gzip over them costs CPU and typically makes the payload a few bytes larger because you've added a compression header to incompressible data. Scope your compression rule to text MIME types (text/*, application/json, application/javascript, image/svg+xml) and let binary media pass through untouched.

Forgetting Vary: Accept-Encoding

A shared cache stores one response per URL unless you tell it otherwise. Without Vary: Accept-Encoding, a cache can hand Brotli bytes to a client that only sent Accept-Encoding: gzip — which then can't decode them. Set the header any time a cache sits between you and clients with different capabilities.

Reflecting a secret into a compressed response

This is the BREACH class of attack. If a response both contains a secret (a CSRF token, part of a session) and reflects attacker-controlled input, an attacker can watch the compressed response size change as they guess the secret byte by byte. The fix isn't "turn off compression" — it's don't reflect user input into responses that carry secrets, and add per-request randomness to those tokens.

Double-compressing through a proxy

An app that gzips its own responses sitting behind a proxy that also gzips can produce Content-Encoding: gzip, gzip. Most clients only unwrap one layer, so the body arrives as still-compressed garbage. Pick one layer to own compression — usually the edge — and have the other pass through.

Picking an algorithm

EncodingRatio on textSpeedBest for
gzipBaselineFastUniversal fallback; dynamic responses
br (Brotli)~15–25% smaller than gzipSlow at level 11, fine at 4–5Pre-compressed static assets
zstdComparable to BrotliVery fastDynamic responses where clients support it

The nuance is the compression level, not just the algorithm. Brotli level 11 gives the best ratio but is too slow to run per-request — use it to pre-compress static assets at build time and serve the stored .br file. For dynamic responses, Brotli 4–5 or Zstd gives most of the benefit at a fraction of the CPU. Zstd's Content-Encoding: zstd only started landing in browsers in 2024 (Chrome 123), so keep gzip in the negotiation as the floor. You can eyeball the ratio a given input actually achieves with a gzip/zlib/deflate tester before wiring it into a config.

Measure both transfer size and origin CPU after any change. Compression that halves your payload but doubles your p99 response time under load isn't a win — it's a trade you should have made on purpose.

Cover photo by Stanislav Kondratiev on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

HTTPPerformanceCompression

Related articles

All articles