Guide
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.
On this page
- How the negotiation works
- The codings you will see
- What to compress and what to skip
- Static (pre-compressed) vs dynamic compression
- Content-Length, chunked transfer and ETags
- Content-Encoding vs Transfer-Encoding
- Server configuration
- nginx with gzip and Brotli
- Caddy
- Express (Node.js)
- Cloudflare
- Measure it yourself
- BREACH: when compression leaks secrets
- Checklist
- Related
TL;DR: The browser lists what it can decode in
Accept-Encoding, the server picks one and labels the body withContent-Encoding: br,gziporzstd, and addsVary: Accept-Encodingso 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 withcurl -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:
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-Encodingheader means any coding is acceptable. Browsers always send one;curlwithout--compresseddoes not, and nginx’s gzip module and Express’scompressionboth 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=0prefers 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
brto a client that asked forgzipproduces 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 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; 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:
# 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-Matchrevalidation but never matchIf-Range. - Cloudflare may drop
Content-Lengthwhen it compresses for visitors. Its docs say to sendCache-Control: no-transformfrom 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).
# 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_typesdefaults totext/htmlonly. Without the directive, your CSS, JavaScript and JSON go out uncompressed.gzip_varydefaults tooff. ngx_brotli has nobrotli_vary; nginx emitsVary: Accept-Encodingfor Brotli responses only whengzip_vary onapplies to that location.gzip_proxieddefaults tooff, which disables compression for any request carrying aViaheader. If a CDN or proxy in front of nginx addsVia, nginx silently stops compressing for it.anycompresses regardless; the finer-grained values (expired,no-cache,private,authand others) compress only responses with matching cache headers.gzip_min_lengthonly looks at the response’sContent-Length. Responses without one, such as chunked upstream responses, are compressed whatever their size.
Caddy
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)
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 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
brorgzipand 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,403and404responses, only for its listed content types, and only above 48 bytes (gzip) or 50 bytes (Brotli and Zstandard). Cache-Control: no-transformfrom the origin stops Cloudflare from changing the encoding and keepsContent-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:
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:
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:
curl -s -D - -o /dev/null -H 'Accept-Encoding: br' https://example.com/ \
| grep -i -E '^(content-encoding|content-length|vary|transfer-encoding|etag):'
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 --compressedsends only the encodings your curl build can decode (look forbrotliandzstdin theFeatures:line ofcurl -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-Encodingwas 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:
- is served with HTTP-level compression,
- reflects user input (for example a query-string parameter echoed into the page), and
- 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 nginxlocation, or thefilterfunction 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
- Text types are listed in
gzip_types/brotli_types(or the equivalent), not justtext/html. - Every compressed response carries
Vary: Accept-Encoding. - Images, video, WOFF2 and archives are excluded.
- Fingerprinted assets are pre-compressed at build time with gzip and Brotli at maximum level, and zstd at level 19 or lower.
- Only one layer compresses. If the CDN compresses, the origin can still send
brorgzip, but the app and nginx should not both try. - Responses that reflect user input and contain secrets use masked tokens or skip compression.
- You have measured with curl, through the CDN and at the origin.
Related
- Accept-Encoding, Content-Encoding and Vary for the header-level reference.
- Transfer-Encoding and Content-Length for message framing.
- HTTP headers and caching for how
Varychanges cache keys. - Range requests for why ranges and on-the-fly compression do not mix.
- Content negotiation for the general mechanism.
- ERR_CONTENT_DECODING_FAILED: what breaks when the
Content-Encodingheader and the body disagree.
Frequently asked questions
How do I check if my site uses gzip or Brotli?
Run curl -s -D - -o /dev/null -H 'Accept-Encoding: br, gzip' https://example.com/ and read the Content-Encoding response header. br means Brotli, gzip means gzip, and no Content-Encoding header means the response went out uncompressed. Repeat with a single encoding in Accept-Encoding to see what the server does for clients that only support that one.
Should I use Brotli or gzip?
Serve both and let Accept-Encoding decide. Every current browser accepts both, and Brotli at the same effort level usually produces smaller text responses, but gzip remains the fallback for older clients, scripts and tools. Measure your own HTML, CSS and JavaScript with curl rather than trusting a generic percentage.
Is zstd supported by browsers?
As of October 2026, caniuse lists zstd content-encoding as supported in Chrome and Edge 123+, Firefox 126+ and Opera 109+. Safari 26 can decode zstd but at first did not advertise it in Accept-Encoding; caniuse shows full support on iOS Safari from 26.3, while desktop Safari is still marked partial (26.3 and later requires macOS 26.3). Samsung Internet has no support. Because servers only send zstd to clients that list it, enabling it is safe as long as gzip and Brotli stay enabled.
Why is Vary: Accept-Encoding important?
It tells shared caches that the response body depends on the Accept-Encoding request header. Without it, a CDN or proxy can store a gzip response and hand it to a client that never asked for gzip. nginx does not send it unless you set gzip_vary on, and the ngx_brotli module relies on that same directive.
Should I compress images and videos?
No for JPEG, PNG, WebP, AVIF, MP4, WebM, WOFF2, ZIP and other formats that are already compressed internally. Running gzip or Brotli over them burns CPU for little or no gain and can make the file larger. SVG, uncompressed fonts such as TTF and OTF, and ICO files are the exceptions worth compressing.
Does HTTPS make compression unsafe?
Not by itself, but compression plus encryption leaks response sizes. The BREACH attack (2013) recovers secrets such as CSRF tokens from compressed HTTPS responses when the same response also reflects attacker-controlled input. Mask tokens per request, keep secrets out of responses that echo user input, or disable compression on those specific responses.
Sources
- MDN Web Docs: Compression in HTTPdeveloper.mozilla.org
- MDN Web Docs: Content-Encodingdeveloper.mozilla.org
- RFC 9110 Section 8.4: Content-Encodingrfc-editor.org
- RFC 9110 Section 12.5.3: Accept-Encodingrfc-editor.org
- RFC 9110 Section 17.6: Attacks Using Shared-Dictionary Compressionrfc-editor.org
- RFC 9112 Section 6.1: Transfer-Encodingrfc-editor.org
- RFC 7932: Brotli Compressed Data Formatrfc-editor.org
- RFC 8878: Zstandard Compressionrfc-editor.org
- RFC 9659: Window Sizing for Zstandard Content Encodingrfc-editor.org
- Can I use: zstd content-encodingcaniuse.com
- nginx: ngx_http_gzip_modulenginx.org
- nginx: ngx_http_gzip_static_modulenginx.org
- google/ngx_brotli (third-party nginx module)github.com
- Caddy: encode directivecaddyserver.com
- Express: compression middlewareexpressjs.com
- Cloudflare: Content compressiondevelopers.cloudflare.com
- CVE-2013-3587 (BREACH)cve.org
Related
Content-Encoding
Learn how Content-Encoding specifies compression algorithms (gzip, br, deflate) used to encode response bodies. Reduce bandwidth and improve load times.
HTTP Headers and Caching: A Practical Guide
Master HTTP caching with Cache-Control, ETag, Last-Modified, and conditional request headers. Learn how to optimize performance with proper cache strategies.
Accept-Encoding Header
Learn how Accept-Encoding tells servers which compression formats (gzip, br, deflate) your client supports to reduce bandwidth and speed up page loads.
Vary Header: Cache Keys, Vary: Origin and Accept-Encoding
Vary lists the request headers a response depends on, so caches keep separate copies. Vary: Accept-Encoding, Vary: Origin for CORS, Vary: *, and CDN cache keys.