Compression

Compression is a trade: CPU time for bytes. That framing is the whole topic, because whether the trade is worth it depends entirely on which of the two you're short of. On a slow mobile connection, spending 5 ms of server CPU to save 300 KB is obviously correct. On a 10 Gbit link between two services in the same rack, spending 5 ms to save 300 KB may be strictly worse than sending it uncompressed.

The confusion in practice comes from defaults that were tuned for a different era. gzip -6 on every response was a sensible default when bandwidth was scarce and payloads were HTML. Today the interesting decisions are: Brotli for static text on the web (meaningfully smaller than gzip), Zstandard for almost everything else (a much better speed-to-ratio curve), precompression at build time so the expensive levels cost nothing at request time, and knowing the specific cases where compression is useless or actively harmful.

There's also a security dimension that catches people out: compressing a response containing both a secret and attacker-controlled input leaks the secret through the compressed length.

TL;DR

Core Concepts

The algorithms

Two things distinguish the modern options. Brotli ships with a built-in dictionary of common web strings (HTML tags, JavaScript keywords, CSS properties), which is why it wins specifically on small web text where there's little internal redundancy to exploit. Zstandard has a far better speed-to-ratio curve than gzip across the whole range — at similar ratios it's several times faster, and it can be trained with a custom dictionary for small, structurally similar payloads.

The gzip -9 row is the point worth taking away: the highest level of any algorithm has terrible marginal returns. Going from 6 to 9 typically costs 3× the CPU for 1–3% smaller output. Nobody should be running gzip -9 on dynamic responses.

Levels: the diminishing return

Practical defaults:

Precompression

The highest-leverage practice in this topic. Compress static assets at build time at maximum level, and have the server pick the right file per request.

gzip_vary on emits Vary: Accept-Encoding, which tells caches that the response differs by encoding. Omitting it means a cache can serve Brotli-compressed bytes to a client that only accepts gzip — a real and confusing bug.

Content negotiation

The server picks from what the client offers, preferring the best it can serve. Note that browsers only advertise br over HTTPS, which is intentional — encoding negotiation over plaintext was a fingerprinting and injection concern.

What not to compress

image/svg+xml and application/wasm are the two easy wins people miss — both are highly compressible and both are often left out of the default type list.

Beyond HTTP

Compression shows up in several places where the tradeoff differs:

Dictionaries

For many small payloads with similar structure — API responses, log lines, protocol messages — a trained dictionary transforms the ratio, because the redundancy is across messages rather than within any one of them.

The catch is that both ends need the same dictionary, which makes this a fit for internal protocols and a poor fit for the open web — though Compression Dictionary Transport is an emerging standard bringing shared dictionaries to HTTP, which is particularly interesting for delta-compressing successive versions of a JavaScript bundle.

Security

⚠️ Warning: Compressing a response that contains both a secret and attacker-controlled input leaks the secret. This is the BREACH attack, and it is a property of compression itself, not a bug in any implementation.

Mitigations, in order of effectiveness:

Most modern frameworks mask CSRF tokens by default specifically for this reason. CRIME was the earlier variant attacking TLS-level compression, which is why TLS compression was removed outright.

The other risk is the decompression bomb — a small compressed payload expanding to gigabytes:

Any service accepting compressed request bodies needs an output size limit. This applies to file uploads, webhook receivers, and any API accepting Content-Encoding: gzip.

Best Practices

Precompress static assets at build time

Brotli 11 and Zstandard 19 on every text asset, served via brotli_static/gzip_static. Maximum ratio, zero per-request CPU. This is free performance and the most commonly skipped step.

Use a modest level for dynamic responses

gzip 4–6, brotli 4–5, or zstd 3. The knee of the ratio curve arrives early, and every level past it costs CPU that adds latency to the request paying for it.

Set a minimum size threshold

Around 1 KB. Below that, gzip's framing overhead plus a poor ratio on short input frequently makes the response larger, and always makes it slower.

Always send Vary: Accept-Encoding

Without it, a shared cache can serve Brotli bytes to a gzip-only client. The failure is intermittent and confusing, and the fix is one directive.

Never compress already-compressed content

Images, video, audio, archives, woff2 fonts, and ciphertext. It costs CPU and occasionally produces a larger response. Audit your gzip_types list — the defaults in copied configurations frequently include image types.

Prefer Zstandard for anything not browser-facing

Internal RPC, Kafka, storage, backups, logs. Its speed-to-ratio curve dominates gzip's across the range, and the decompression speed advantage is large enough to matter on read-heavy paths.

Train a dictionary for many small similar payloads

API responses, log lines, protocol messages. When the redundancy is across messages rather than within them, a dictionary can improve the ratio several-fold — and it's the only technique that helps at small payload sizes.

Mask CSRF tokens, and don't mix secrets with reflected input

The BREACH mitigation. Most frameworks do the masking by default; verify yours does, and treat "a page containing both a token and a reflected search term" as a finding.

Measure the end-to-end effect, not the ratio

A better ratio that costs 30 ms of server CPU can be slower overall on a fast connection. Measure time-to-last-byte from a realistic client on a realistic network, and compare against uncompressed.

Common Mistakes

Compressing images

Maximum level on dynamic responses

Missing Vary: Accept-Encoding

Compressing tiny responses

Unbounded decompression

Compressing a response with a secret and reflected input

FAQ

Brotli or Zstandard for a website?

Brotli for static text served to browsers — its built-in web dictionary gives it a real edge on HTML, CSS, and JavaScript, and browser support is universal over HTTPS. Zstandard's browser support is growing but not yet universal, so it's a progressive enhancement there. For everything not browser-facing, Zstandard is the better default. Serving both, with gzip as the fallback, costs nothing when assets are precompressed.

Is gzip -9 ever worth it?

Only for content compressed once and served many times — precompressed static assets, backups, archives. Even then, Brotli 11 or Zstandard 19 will beat it. For anything compressed per request, -9 is a straightforward mistake: roughly 3× the CPU for 1–3% smaller output.

Should I compress between internal services?

Measure it. On a 10 Gbit link within a data center, compressing a 50 KB response may cost more in CPU and latency than the transfer saves. On cross-region or cross-cloud links where bandwidth is metered and latency is higher, it's usually worth it. Zstandard at level 1–3 is the right setting when you do — fast enough that the trade is favorable at much lower bandwidth thresholds than gzip.

Why doesn't compressing my JSON API help much?

Three common reasons. The responses may be below the ~1 KB threshold where compression has little to work with. The payload may already be compact — short keys and numeric values compress less than verbose text. Or the client may not be sending Accept-Encoding (some HTTP clients don't by default). For many small responses, a trained Zstandard dictionary is the technique that actually helps.

Does compression help or hurt Core Web Vitals?

It generally helps substantially — smaller JavaScript and CSS means faster download, which improves LCP directly. The caveat is that decompression is CPU work on the client, and on a low-end phone a heavily Brotli-compressed bundle takes measurable time to decompress before parsing begins. In practice the transfer saving dominates on real networks, and precompression means the server-side cost is zero. See Core Web Vitals.

How real is BREACH?

Real, demonstrated, and still applicable — it's a property of compressing a secret alongside attacker-controlled data, not an implementation bug that got patched. Exploitation requires the attacker to observe response sizes and make many requests, which is achievable in several scenarios. The standard mitigation (per-response CSRF token masking) is now default in most frameworks, which is why it's less discussed than it was. Verify your framework does it rather than assuming.

Related Topics

References