How HTTP Works

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.

Reviewed 13 min readintermediate17 sourcesTry itMarkdown
On this page

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:

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 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

TokenFormatSpecNotes
gzipDEFLATE with a gzip header and CRC-32RFC 9110 §8.4.1.3, RFC 1952Universally supported. Recipients should treat x-gzip as the same thing.
deflateDEFLATE inside a zlib wrapperRFC 9110 §8.4.1.2, RFC 1950RFC 9110 notes that some implementations send it without the zlib wrapper. Prefer gzip.
brBrotliRFC 7932Levels 0–11. Supported by all current browsers.
zstdZstandardRFC 8878, RFC 9659Newest of the four. Encoders must not use a window larger than 8 MB for HTTP.
compressLZWRFC 9110 §8.4.1.1Historical. 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:

StackDefault minimum size
nginx gzip_min_length20 bytes, read only from the Content-Length response header
ngx_brotli brotli_min_length20 bytes
Caddy encode minimum_length512 bytes
Express compression threshold1 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-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-EncodingTransfer-Encoding
Defined inRFC 9110 §8.4RFC 9112 §6.1 (HTTP/1.1 only)
DescribesThe representationThis one message on this one hop
Typical valuesgzip, br, zstdchunked (occasionally gzip, chunked)
Survives cachingYes; caches store the encoded bytesNo; any hop may remove or add it
ETag and ranges apply toThe encoded bytesNot affected
In HTTP/2 and HTTP/3Same meaningNot 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_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

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 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:

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 --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.

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

  1. MDN Web Docs: Compression in HTTPdeveloper.mozilla.org
  2. MDN Web Docs: Content-Encodingdeveloper.mozilla.org
  3. RFC 9110 Section 8.4: Content-Encodingrfc-editor.org
  4. RFC 9110 Section 12.5.3: Accept-Encodingrfc-editor.org
  5. RFC 9110 Section 17.6: Attacks Using Shared-Dictionary Compressionrfc-editor.org
  6. RFC 9112 Section 6.1: Transfer-Encodingrfc-editor.org
  7. RFC 7932: Brotli Compressed Data Formatrfc-editor.org
  8. RFC 8878: Zstandard Compressionrfc-editor.org
  9. RFC 9659: Window Sizing for Zstandard Content Encodingrfc-editor.org
  10. Can I use: zstd content-encodingcaniuse.com
  11. nginx: ngx_http_gzip_modulenginx.org
  12. nginx: ngx_http_gzip_static_modulenginx.org
  13. google/ngx_brotli (third-party nginx module)github.com
  14. Caddy: encode directivecaddyserver.com
  15. Express: compression middlewareexpressjs.com
  16. Cloudflare: Content compressiondevelopers.cloudflare.com
  17. CVE-2013-3587 (BREACH)cve.org

Keep going

Browse /search