# HTTP Compression: gzip, Brotli and Zstandard Setup

> How HTTP compression works: Accept-Encoding negotiation, gzip vs Brotli vs zstd, nginx, Caddy, Express and Cloudflare config, BREACH, and measuring with curl.

Source: https://howhttpworks.com/guides/http-compression
Last reviewed: 2026-10-04

> **TL;DR:** The browser lists what it can decode in `Accept-Encoding`, the server picks one and labels the body with `Content-Encoding: br`, `gzip` or `zstd`, and adds `Vary: Accept-Encoding` so caches keep the variants apart. Compress text (HTML, CSS, JS, JSON, SVG), skip already-compressed media and tiny responses, pre-compress static assets at build time, and verify each layer with `curl -H 'Accept-Encoding: …' -w '%{size_download}'` rather than trusting a config file.

## How the negotiation works

Compression in HTTP is ordinary content negotiation. The client advertises the content codings it can decode, the server chooses one, and the response says which one was applied:

```http
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br, zstd

HTTP/1.1 200 OK
Content-Type: text/javascript
Content-Encoding: br
Vary: Accept-Encoding
Cache-Control: public, max-age=31536000, immutable
```

The rules that trip people up are in RFC 9110 §12.5.3:

- **No `Accept-Encoding` header** means any coding is acceptable. Browsers always send one; `curl` without `--compressed` does not, and nginx's gzip module and Express's `compression` both answer such requests uncompressed.
- **An empty `Accept-Encoding:`** means the client wants no coding at all.
- **q-values rank choices.** `gzip;q=1.0, identity;q=0.5, *;q=0` prefers gzip, accepts uncompressed, and refuses everything else. When codings tie, the server picks.
- **The server must only use a coding the client listed** (or any coding if the header is missing). Sending `br` to a client that asked for `gzip` produces a garbled body, not an error page.

`Content-Encoding` (RFC 9110 §8.4) is a property of the representation: the `ETag`, `Content-Length` and byte offsets for range requests all describe the encoded bytes, not the decoded file. That is why one URL can have three ETags, one per encoding, and why [range requests](https://howhttpworks.com/guides/range-requests) and compression interact the way they do.

`Vary: Accept-Encoding` (RFC 9110 §12.5.5) tells every cache that the body depends on that request header, so the cache key must include it. Leave it out and a shared cache can store the `br` variant and serve it to a client that only understands gzip. How caches build keys from `Vary` is covered in [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching); for compression the rule is simply: if the server ever varies the encoding, send `Vary: Accept-Encoding` on every variant, including the uncompressed one.

## The codings you will see

| Token | Format | Spec | Notes |
| --- | --- | --- | --- |
| `gzip` | DEFLATE with a gzip header and CRC-32 | RFC 9110 §8.4.1.3, RFC 1952 | Universally supported. Recipients should treat `x-gzip` as the same thing. |
| `deflate` | DEFLATE inside a zlib wrapper | RFC 9110 §8.4.1.2, RFC 1950 | RFC 9110 notes that some implementations send it without the zlib wrapper. Prefer gzip. |
| `br` | Brotli | RFC 7932 | Levels 0–11. Supported by all current browsers. |
| `zstd` | Zstandard | RFC 8878, RFC 9659 | Newest of the four. Encoders must not use a window larger than 8 MB for HTTP. |
| `compress` | LZW | RFC 9110 §8.4.1.1 | Historical. Do not serve it. |

**Zstandard support, as of 2026-10-04.** caniuse lists `zstd` content-encoding as supported in Chrome and Edge from 123, Firefox from 126 and Opera from 109. Safari 26.0–26.2 is marked partial because it decodes zstd but does not request it in `Accept-Encoding`; caniuse shows full support on iOS Safari from 26.3, while desktop Safari 26.3 and later is still marked partial with a note that it requires macOS 26.3. Samsung Internet has no support. In practice a server only sends `zstd` when the client lists it, so turning it on next to gzip and Brotli cannot break older clients.

**The zstd window rule.** RFC 9659 makes the 8 MB `Window_Size` limit mandatory for the `zstd` content coding because browsers cap decoder memory. The `zstd` CLI stays inside that limit at levels 1–19, but `--ultra` levels 20–22 and `--long` raise the window above 8 MB, so a pre-compressed file larger than 8 MB built with those flags can fail to decode in the browser.

## What to compress and what to skip

Compress text formats: `text/html`, `text/css`, `text/javascript` and `application/javascript`, `application/json`, `application/xml`, `image/svg+xml`, `application/manifest+json`, `application/wasm`, uncompressed fonts (`font/ttf`, `font/otf`) and `image/x-icon`.

Skip formats that are already compressed internally: JPEG, PNG, GIF, WebP, AVIF, MP4, WebM, MP3, WOFF2 (which uses Brotli inside the font container), ZIP, and anything already gzipped. MDN's guidance is blunt: recompressing `.zip` or `.jpeg` "is usually not appropriate because it can increase the file size." You pay CPU on every request for nothing.

Skip tiny responses. A gzip member alone carries a 10-byte header and an 8-byte trailer (RFC 1952), so a 40-byte JSON reply can grow. Each stack has its own threshold:

| Stack | Default minimum size |
| --- | --- |
| nginx `gzip_min_length` | 20 bytes, read only from the `Content-Length` response header |
| ngx_brotli `brotli_min_length` | 20 bytes |
| Caddy `encode` `minimum_length` | 512 bytes |
| Express `compression` `threshold` | 1 KB |
| Cloudflare (to visitors) | 48 bytes for gzip, 50 bytes for Brotli and Zstandard |

nginx's own example config sets `gzip_min_length 1000;`, which is a more sensible floor than the 20-byte default.

Do not compress streaming responses unless the compressor flushes. Server-sent events behind a buffering compressor arrive in bursts or not at all. Caddy's `encode` bypasses its size threshold for server-sent events, and Express's `compression` adds `res.flush()` for exactly this case.

## Static (pre-compressed) vs dynamic compression

**Dynamic compression** runs on every response as it leaves the server. It works for HTML and API responses that differ per request, but it costs CPU per request, so servers use a moderate level: nginx's `gzip_comp_level` defaults to 1, ngx_brotli's `brotli_comp_level` to 6, and Express's middleware sets Brotli quality to 4 instead of Node's default of 11, the slowest setting.

**Static compression** happens once, at build or deploy time, at the highest level, and the server just picks the matching file. It is the right choice for fingerprinted CSS and JavaScript bundles:

```bash
# keep the original; write app.js.gz, app.js.br and app.js.zst alongside it
gzip -k -9 dist/assets/app.js
brotli -k -q 11 dist/assets/app.js
zstd -19 dist/assets/app.js -o dist/assets/app.js.zst
```

The server needs both the original and the sidecar files. nginx serves `.gz` with `gzip_static on;` (the `ngx_http_gzip_static_module` is not built by default; check `nginx -V` for `--with-http_gzip_static_module`) and `.br` with ngx_brotli's `brotli_static on;`. Caddy serves `.br`, `.zst` and `.gz` with `file_server { precompressed }`. nginx recommends that the original and compressed files carry the same modification time.

A pre-compressed file has a fixed size, so the response carries an exact `Content-Length` and a stable strong `ETag`. Dynamic compression gives up both, as the next section shows.

## Content-Length, chunked transfer and ETags

A dynamic compressor does not know the compressed size until it has finished, but HTTP headers go out first. So servers that compress on the fly drop `Content-Length` and frame the body another way:

- **HTTP/1.1** uses `Transfer-Encoding: chunked` (RFC 9112 §7.1).
- **HTTP/2 and HTTP/3** use DATA frames and end the stream with a flag. RFC 9113 §8.1 says the chunked transfer encoding "cannot be used in HTTP/2".

The source of nginx's gzip filter shows what happens to a response it compresses: it clears `Content-Length`, clears `Accept-Ranges`, and converts a strong `ETag` to a weak one (`W/"…"`). The ngx_brotli filter does the same. It also only compresses `200`, `403` and `404` responses and skips any response that already has a `Content-Encoding`, so an app that compresses its own output is not compressed twice. Express's `compression` similarly removes `Content-Length` and skips `HEAD` requests, so a `HEAD` can report a different length from the `GET`.

Consequences you will notice:

- Download progress bars show an unknown size for dynamically compressed responses.
- Range requests stop working on those responses (nginx removes `Accept-Ranges`), which is one reason never to compress video.
- Weak ETags still work for `If-None-Match` revalidation but never match `If-Range`.
- Cloudflare may drop `Content-Length` when it compresses for visitors. Its docs say to send `Cache-Control: no-transform` from the origin to preserve the header, which also stops Cloudflare from changing the encoding.

## Content-Encoding vs Transfer-Encoding

| | `Content-Encoding` | `Transfer-Encoding` |
| --- | --- | --- |
| Defined in | RFC 9110 §8.4 | RFC 9112 §6.1 (HTTP/1.1 only) |
| Describes | The representation | This one message on this one hop |
| Typical values | `gzip`, `br`, `zstd` | `chunked` (occasionally `gzip, chunked`) |
| Survives caching | Yes; caches store the encoded bytes | No; any hop may remove or add it |
| ETag and ranges apply to | The encoded bytes | Not affected |
| In HTTP/2 and HTTP/3 | Same meaning | Not allowed |

`Transfer-Encoding: gzip` exists on paper, but browsers do not request it, so in practice compression on the web means `Content-Encoding` and `Transfer-Encoding` means `chunked`. If you see both `Content-Length` and `Transfer-Encoding` on one HTTP/1.1 message, RFC 9112 says `Transfer-Encoding` wins and the message "ought to be handled as an error", because that combination is the shape of a request-smuggling attack.

## Server configuration

### nginx with gzip and Brotli

Stock nginx ships gzip (`ngx_http_gzip_module`), pre-compressed gzip (`ngx_http_gzip_static_module`) and `ngx_http_gunzip_module`. It has no Brotli or Zstandard module of its own. Brotli comes from **ngx_brotli, a third-party module** maintained under Google's GitHub organisation; you build it as a dynamic module against your nginx version or install a distribution package (Debian ships `libnginx-mod-http-brotli-filter` and `libnginx-mod-http-brotli-static`).

```nginx
# Main context, before http {}. Paths depend on how the module was built or packaged.
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

http {
    gzip            on;
    gzip_vary       on;        # Vary: Accept-Encoding; also used by ngx_brotli
    gzip_comp_level 5;
    gzip_min_length 1000;
    gzip_proxied    any;       # see note below
    # text/html is always compressed; do not list it
    gzip_types text/plain text/css text/xml text/javascript application/javascript
               application/json application/xml application/manifest+json
               application/wasm image/svg+xml image/x-icon font/ttf font/otf;

    brotli            on;
    brotli_comp_level 5;
    brotli_min_length 1000;
    brotli_types text/plain text/css text/xml text/javascript application/javascript
                 application/json application/xml application/manifest+json
                 application/wasm image/svg+xml image/x-icon font/ttf font/otf;

    # Serve app.js.gz / app.js.br when they exist next to app.js
    gzip_static   on;
    brotli_static on;
}
```

Details that bite:

- `gzip_types` defaults to `text/html` only. Without the directive, your CSS, JavaScript and JSON go out uncompressed.
- `gzip_vary` defaults to `off`. ngx_brotli has no `brotli_vary`; nginx emits `Vary: Accept-Encoding` for Brotli responses only when `gzip_vary on` applies to that location.
- `gzip_proxied` defaults to `off`, which disables compression for any request carrying a `Via` header. If a CDN or proxy in front of nginx adds `Via`, nginx silently stops compressing for it. `any` compresses regardless; the finer-grained values (`expired`, `no-cache`, `private`, `auth` and others) compress only responses with matching cache headers.
- `gzip_min_length` only looks at the response's `Content-Length`. Responses without one, such as chunked upstream responses, are compressed whatever their size.

### Caddy

```text
example.com {
    root * /srv/site
    encode zstd gzip
    file_server {
        precompressed br zstd gzip
    }
}
```

`encode` compresses on the fly and supports only `zstd` and `gzip`; with no arguments it enables both and prefers zstd. When the client has no q-value preference, the first listed encoding wins. For Brotli in Caddy, ship `.br` files and let `file_server`'s `precompressed` option pick them up; its default order is `br zstd gzip`.

### Express (Node.js)

```javascript
import express from 'express'
import compression from 'compression'

const app = express()

app.use(
  compression({
    threshold: '1kb', // the default
    filter: (req, res) => {
      // Example: never compress pages that mix secrets with reflected input
      if (req.path.startsWith('/account')) return false
      return compression.filter(req, res)
    }
  })
)

app.use(express.static('dist'))
```

`compression` 1.8.0 (February 2025) added Brotli alongside gzip and deflate; earlier versions only do gzip and deflate. It does not do zstd. It sets `Vary: Accept-Encoding`, never compresses a response that carries `Cache-Control: no-transform`, decides compressibility from `Content-Type` using the `compressible` package, and exposes `res.flush()` for server-sent events. In production, most teams put nginx, Caddy or a CDN in front and let that layer compress instead.

### Cloudflare

From Cloudflare's [content compression](https://developers.cloudflare.com/speed/optimization/content/compression/) documentation (last updated April 2026):

- To visitors, Cloudflare uses gzip, Brotli or Zstandard based on the visitor's `Accept-Encoding`, the plan and any Compression Rules. The documented defaults are Zstandard on Free, Brotli on Pro and Business, and gzip on Enterprise.
- To your origin, Cloudflare always sends `Accept-Encoding: br, gzip`. It never asks the origin for zstd.
- If the origin's response is already `br` or `gzip` and the visitor supports it, Cloudflare passes it through, unless a feature that rewrites the body (Rocket Loader, Email Address Obfuscation, Automatic HTTPS Rewrites and others) forces a decompress and recompress.
- Cloudflare compresses only `200`, `403` and `404` responses, only for its listed content types, and only above 48 bytes (gzip) or 50 bytes (Brotli and Zstandard).
- `Cache-Control: no-transform` from the origin stops Cloudflare from changing the encoding and keeps `Content-Length`.

## Measure it yourself

Compression ratios depend entirely on your content, so measure your own responses instead of quoting someone else's percentages. `%{size_download}` is the number of body bytes that crossed the wire. When you set `Accept-Encoding` by hand, curl does not decode the body, so the figure is the compressed size:

```bash
curl -s -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}\n' https://example.com/
```

Compare every encoding for the same URL, and print the status code so you notice a redirect or error page:

```bash
url='https://example.com/assets/app.js'
for enc in identity gzip br zstd; do
  curl -s -H "Accept-Encoding: $enc" -o /dev/null \
    -w "$enc\t%{http_code}\t%{size_download} bytes\n" "$url"
done
```

If the `zstd` row shows the same byte count as `identity`, the server ignored zstd and sent the response uncompressed. Confirm which encoding actually came back by reading the headers:

```bash
curl -s -D - -o /dev/null -H 'Accept-Encoding: br' https://example.com/ \
  | grep -i -E '^(content-encoding|content-length|vary|transfer-encoding|etag):'
```

```text
content-encoding: br
vary: Accept-Encoding
```

Run the same commands against the origin directly and through the CDN; they often disagree. Two more checks:

- `curl --compressed` sends only the encodings your curl build can decode (look for `brotli` and `zstd` in the `Features:` line of `curl -V`), so it is not a reliable way to test Brotli or zstd.
- In browser DevTools, compare the transferred size of a request with its decoded resource size in the Network panel; the request's response headers show which `Content-Encoding` was used.

## BREACH: when compression leaks secrets

BREACH ("Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext") was presented by Yoel Gluck, Neal Harris and Angelo Prado at Black Hat USA 2013 and tracked as CVE-2013-3587. RFC 9110 §17.6 cites it. It is the HTTP-level successor to CRIME, which attacked TLS-level compression; disabling TLS compression does not help against BREACH, and the researchers note it works regardless of TLS version or cipher suite.

The researchers list three conditions. A response is at risk when it:

1. is served with HTTP-level compression,
2. reflects user input (for example a query-string parameter echoed into the page), and
3. contains a secret such as a CSRF token or session-bound value.

An attacker who can make the victim's browser send many requests, and observe the size of the encrypted responses, guesses the secret one character at a time: when the guess in the reflected input matches the secret, the compressor finds the repetition and the response gets a few bytes smaller.

Mitigations, roughly from most to least targeted:

- **Mask secrets per response.** Django's CSRF token is "scrambled differently with each response using a mask" for exactly this reason. If your framework emits the same raw token on every page, fix that first.
- **Separate secrets from reflected input.** Keep tokens out of responses that echo query parameters or form fields.
- **Disable compression for those responses only**, for example `gzip off; brotli off;` in one nginx `location`, or the `filter` function in the Express example above. Turning compression off site-wide is rarely necessary.
- **Rate-limit and monitor** requests that look like guessing: thousands of near-identical requests from one client.

Static assets and responses that contain no secrets are not affected, so pre-compressed CSS and JavaScript are always safe.

## Checklist

1. Text types are listed in `gzip_types` / `brotli_types` (or the equivalent), not just `text/html`.
2. Every compressed response carries `Vary: Accept-Encoding`.
3. Images, video, WOFF2 and archives are excluded.
4. Fingerprinted assets are pre-compressed at build time with gzip and Brotli at maximum level, and zstd at level 19 or lower.
5. Only one layer compresses. If the CDN compresses, the origin can still send `br` or `gzip`, but the app and nginx should not both try.
6. Responses that reflect user input and contain secrets use masked tokens or skip compression.
7. You have measured with curl, through the CDN and at the origin.

## Related

- [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding), [Content-Encoding](https://howhttpworks.com/headers/content-encoding) and [Vary](https://howhttpworks.com/headers/vary) for the header-level reference.
- [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding) and [Content-Length](https://howhttpworks.com/headers/content-length) for message framing.
- [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for how `Vary` changes cache keys.
- [Range requests](https://howhttpworks.com/guides/range-requests) for why ranges and on-the-fly compression do not mix.
- [Content negotiation](https://howhttpworks.com/glossary/content-negotiation) for the general mechanism.
- [ERR_CONTENT_DECODING_FAILED](https://howhttpworks.com/debug/err-content-decoding-failed): what breaks when the `Content-Encoding` header and the body disagree.
