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
| Client | Accept-Encoding sent | Decoded br / zstd? |
|---|---|---|
Chromium 153, https:// (self-signed, localhost) | gzip, deflate, br, zstd | yes / yes |
Chromium 153, http://localhost | gzip, deflate, br, zstd | yes / yes |
Chromium 153, plain http:// to a LAN IP | gzip, deflate | no: ERR_CONTENT_DECODING_FAILED |
| curl 8.14.1 (no flags) | none sent | no, prints the raw bytes |
curl 8.14.1 --compressed | deflate, gzip, br, zstd | yes / yes |
| Python requests 2.32.3 (no brotli package) | gzip, deflate | no: .content was the raw 53-byte br body |
| Python urllib.request 3.13 | identity | no (not even gzip) |
Node 24.21.0 fetch | gzip, deflate | yes / 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 sends | Chromium 153 | curl --compressed |
|---|---|---|
| Correct gzip, br, zstd or deflate (advertised) | OK | OK |
Content-Encoding: br to a client that offered only gzip, deflate | net::ERR_CONTENT_DECODING_FAILED | n/a |
gzip body labelled br (or br body labelled gzip) | net::ERR_CONTENT_DECODING_FAILED; fetch() rejects with TypeError: Failed to fetch | exit 61, 0 bytes |
Uncompressed body labelled gzip | net::ERR_CONTENT_DECODING_FAILED | exit 61 |
Unknown value Content-Encoding: xyz, plain body | shown 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
| Setting | 12,941-byte HTML page | 245,232-byte JS (acorn.js) | Time for the JS |
|---|---|---|---|
| gzip level 6 | 4,010 | 58,156 | 6.4 ms |
| gzip level 9 | 4,010 | 57,868 | 15.4 ms |
| br quality 4 | 4,042 | 59,818 | 3.6 ms |
| br quality 11 | 3,296 | 47,313 | 466 ms |
| zstd level 3 | 4,211 | 63,246 | 1.4 ms |
| zstd level 19 | 3,949 | 50,455 | 102 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.