Accept-Encoding: gzip, deflate, br, zstd (tested)

Accept-Encoding is the list of compressions the client can unpack; the server answers with one of them in Content-Encoding. We checked what browsers and common clients actually advertise, what breaks, and how much Brotli saves over gzip.

1. What each client sent

ClientAccept-Encoding sentDecoded br / zstd?
Chromium 153, https:// (self-signed, localhost)gzip, deflate, br, zstdyes / yes
Chromium 153, http://localhostgzip, deflate, br, zstdyes / yes
Chromium 153, plain http:// to a LAN IPgzip, deflateno: ERR_CONTENT_DECODING_FAILED
curl 8.14.1 (no flags)none sentno, prints the raw bytes
curl 8.14.1 --compresseddeflate, gzip, br, zstdyes / yes
Python requests 2.32.3 (no brotli package)gzip, deflateno: .content was the raw 53-byte br body
Python urllib.request 3.13identityno (not even gzip)
Node 24.21.0 fetchgzip, deflateyes / yes, although it did not ask

The key result: Chromium only offers br and zstd on secure origins (https and localhost). Testing on http://localhost shows br working, then the same server on a plain-http IP or hostname gets gzip, deflate. Always pick the encoding from the request's Accept-Encoding; never send br because "all browsers support it".

2. What breaks

Server sendsChromium 153curl --compressed
Correct gzip, br, zstd or deflate (advertised)OKOK
Content-Encoding: br to a client that offered only gzip, deflatenet::ERR_CONTENT_DECODING_FAILEDn/a
gzip body labelled br (or br body labelled gzip)net::ERR_CONTENT_DECODING_FAILED; fetch() rejects with TypeError: Failed to fetchexit 61, 0 bytes
Uncompressed body labelled gzipnet::ERR_CONTENT_DECODING_FAILEDexit 61
Unknown value Content-Encoding: xyz, plain bodyshown as-is, no error-

Node fetch on the mislabelled body threw TypeError: terminated with cause Decompression failed. ERR_CONTENT_DECODING_FAILED almost always means the label and the bytes disagree: typically a proxy or app compresses a body that is already compressed, or strips the compression but keeps the header.

3. Brotli vs gzip vs zstd: measured sizes

Setting12,941-byte HTML page245,232-byte JS (acorn.js)Time for the JS
gzip level 64,01058,1566.4 ms
gzip level 94,01057,86815.4 ms
br quality 44,04259,8183.6 ms
br quality 113,29647,313466 ms
zstd level 34,21163,2461.4 ms
zstd level 193,94950,455102 ms

Brotli's advantage comes from high quality: br 11 was 18% smaller than gzip 9 on the script, but about 30 times slower. At fast settings (br 4, zstd 3) the output was about the same size as gzip 6 or slightly larger. So: pre-compress static files with br 11 at build time, and use gzip, a low br quality or zstd for responses compressed on the fly.

4. Serving pre-compressed files (tested Node example)

import http from 'node:http'; import fs from 'node:fs';
http.createServer((req, res) => {
  const file = 'dist/app.js';                // map req.url to a file in real code
  const ae = req.headers['accept-encoding'] || '';
  const headers = { 'Content-Type': 'text/javascript', 'Vary': 'Accept-Encoding' };
  let path = file;
  if (/\bbr\b/.test(ae) && fs.existsSync(file + '.br')) { path += '.br'; headers['Content-Encoding'] = 'br'; }
  else if (/\bgzip\b/.test(ae) && fs.existsSync(file + '.gz')) { path += '.gz'; headers['Content-Encoding'] = 'gzip'; }
  headers['Content-Length'] = fs.statSync(path).size;
  res.writeHead(200, headers); fs.createReadStream(path).pipe(res);
}).listen(8080);

Results with curl: no Accept-Encoding gave 245,232 bytes uncompressed, gzip, deflate gave gzip at 57,868 bytes, and Chrome's gzip, deflate, br, zstd gave br at 47,313 bytes; the --compressed output was byte-identical to the original. Create the files with zlib.brotliCompressSync(buf, {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: 11}}) and zlib.gzipSync(buf, {level: 9}). Vary: Accept-Encoding stops shared caches from serving br to a client that cannot read it. This simple regex ignores q=0; a full parser should honour it.

5. Checking with curl

# ask like Chrome on https, show what came back
curl -s -o /dev/null -D- -H 'Accept-Encoding: gzip, deflate, br, zstd' https://example.com/ | grep -i -E 'content-encoding|vary'

# download and decode (curl 8.14.1 built with brotli and zstd: check 'curl --version' Features)
curl --compressed -o page.html https://example.com/

Without --compressed curl sends no Accept-Encoding and, if a server compresses anyway, writes the compressed bytes to your terminal. For nginx specifically (stock image has no Brotli module) see Content-Encoding gzip vs br in nginx. Related: Vary and caching, Content-Length mismatches and chunked vs Content-Length.

Frequently asked questions

Why does Chrome send Accept-Encoding: gzip, deflate, br, zstd?

It can decode all four. In our Chromium 153 test it sent that list on https and on http://localhost, but only 'gzip, deflate' on plain http to a non-localhost address.

Is Brotli (br) better than gzip?

At high quality, yes: br 11 made a 245,232-byte script 47,313 bytes vs 57,868 for gzip 9 (18% smaller) but took 466 ms vs 15 ms. At quality 4 it was no smaller than gzip 6. Pre-compress static files with br 11.

What causes ERR_CONTENT_DECODING_FAILED?

The Content-Encoding header does not match the body or the client: a gzip body labelled br, a plain body labelled gzip, or br sent to a Chromium page on plain http that only offered gzip, deflate. All three failed in our test.

Does curl support Brotli?

Yes if built with it: curl 8.14.1 on Debian lists brotli and zstd in Features. With --compressed it sends 'deflate, gzip, br, zstd' and decodes them; without it it sends no Accept-Encoding.

Does Python requests decode br?

Only with the brotli package installed. Without it requests 2.32.3 sent 'gzip, deflate', and a br response came back as raw compressed bytes. Node 24 fetch decoded br and zstd even though it only advertised gzip, deflate.

Written with AI assistance. Measured on 2026-10-01 with headless Chromium 153.0.8010.12 (Playwright), curl 8.14.1 (libcurl with brotli 1.1.0 and zstd 1.5.7), Python 3.13 with requests 2.32.3 / urllib3 2.3.0 (no brotli or zstandard package installed) and Node 24.21.0 (fetch and zlib) against a Node test server on http://localhost, http on a non-localhost IP, and https://localhost with a self-signed certificate. Sizes and times are Node zlib on one machine; compare settings, not absolute numbers. PlainHeaders is our extension; curl and Node are free.