gzip or brotli for a JSON API?

Brotli, but at quality 4 or 5 — never the default 11. Measured on Node 23 with node:zlib over realistic JSON. Brotli q4 produced a smaller 200 KB response than gzip -6 in 0.82 ms against 1.39 ms; brotli's default q11 took 179 ms for the same body, and 5.2 to 6.6 seconds for a 5 MB export. Includes t

A heavy stack, and the single small layer that ships

Brotli, at quality 4 or 5 — not at the default of 11. On a 200 KB JSON list, brotli quality 4 produced a smaller body than gzip -6 (38,266 bytes against 38,775) in 0.82 ms against gzip's 1.39 ms. Brotli's default quality 11 produced 30,065 bytes for the same input and took 179 ms — 129 times gzip -6's CPU to save a further 22% of bytes. On a 5 MB export the default took 5.2 to 6.6 seconds. That single default is the whole gzip vs brotli argument, and almost every wrong answer comes from benchmarking quality 11 and concluding brotli is slow.

Hardware: Apple M3, 16 GB, macOS 26.4.1. Node v23.5.0, bundled zlib 1.3.0.1-motley-82a5fec and brotli 1.1.0, node:zlib only, no network, no container. These are indicative figures from one laptop under normal desktop load, not a lab benchmark. Compressed sizes were byte-identical across every run; timings are medians of 5 to 201 runs with the spread shown. The ratios are the part worth quoting.

The short answer

  • Brotli quality 4 dominates gzip -6 on both axes. On a 200 KB JSON list it was 1.3% smaller and 1.69x faster to compress; on a 5 MB export, 7.1% smaller and 1.5–2.0x faster. There is no trade — it is smaller and cheaper.
  • Brotli's default quality of 11 is unusable for dynamic responses. 179 ms for a 200 KB body against gzip -6's 1.39 ms, and 5.2–6.6 seconds for a 5 MB one. Under 32 concurrent requests on Node's default 4-thread pool it managed 14–17 requests per second against gzip -6's 1,695–2,121.
  • Every level has a break-even link speed: bytes saved ÷ CPU time. On 200 KB that is 867 Mbit/s for gzip -6, 1,415 Mbit/s for brotli q4 and 8 Mbit/s for brotli q11. Above it, compressing costs wall-clock time.
  • Compressing a base64 image field is worth it, but not for the reason you think. A JSON body carrying a base64 PNG went 496,654 → 373,895 bytes under gzip -6 — a 1.33x ratio that is exactly base64's 33% expansion coming back off. Compressing the raw PNG instead added 133 bytes and cost 4.95 ms.
  • Brotli does not decompress faster than gzip on this data — it is slower. 0.269 ms against 0.149 ms for the 200 KB body at the default quality, 1.81x. Clients pay a small tax for brotli, not a bonus.

What was measured, and on what

Three payloads of realistic JSON, generated deterministically from a seeded PRNG: a 1,897-byte API response (an order with nested customer, address and line items), a 200,380-byte paginated list of 137 of those, and a 5,081,402-byte bulk export of 3,400.

This matters more than any other methodology choice here. Random bytes do not compress, so a benchmark built on random data reports a ratio near 1.0 for every codec and recommends turning compression off. Real API JSON is the opposite extreme: repeated key names, ISO timestamps sharing a prefix, enums drawn from a handful of strings, UUIDs in a fixed shape. That structure is what the ratios below measure, and why they will not transfer to an image endpoint.

Each codec ran through zlib.gzipSync, zlib.deflateSync and zlib.brotliCompressSync at every level, with the round trip asserted byte-for-byte. Deflate is the same algorithm as gzip with cheaper framing — 6 bytes of zlib wrapper against gzip's 18 — and came out exactly 12 bytes smaller at every level on all three payloads, identical otherwise, so it is not in the tables below. If you were hoping Content-Encoding: deflate was a hidden win, it is 12 bytes.

Which brotli quality matches gzip's speed?

Quality 4, and it does not merely match — it beats gzip -6 on both size and time. The 200,380-byte list, medians of 25 runs:

200 KB JSON list output ratio compress (median) spread decompress
gzip -1 46,508 4.31x 0.548 ms 0.479 – 0.812 0.162 ms
gzip -6 (default) 38,775 5.17x 1.387 ms 1.275 – 1.472 0.149 ms
gzip -9 37,432 5.35x 1.921 ms 1.845 – 2.212 0.153 ms
brotli q4 38,266 5.24x 0.820 ms 0.780 – 0.975 0.181 ms
brotli q5 35,482 5.65x 1.774 ms 1.694 – 1.961 0.174 ms
brotli q9 33,932 5.91x 3.275 ms 3.048 – 3.724 0.173 ms
brotli q10 31,028 6.46x 58.815 ms 57.565 – 60.897 0.270 ms
brotli q11 (default) 30,065 6.66x 179.084 ms 177.745 – 186.091 0.269 ms

Two things fall out of that table. The first is the cliff between q9 and q10: 3.3 ms to 58.8 ms, an 18x jump for 8.6% fewer bytes. Quality 10 turns on a much more expensive match search, and both levels above the cliff are priced for something you compress once, not per request.

The second is that brotli q5 matches gzip -6's time budget — 1.774 ms against 1.387 ms, both under 2 ms — while producing 8.5% fewer bytes. Take q4 if you want the strictly-better option; q5 if you have gzip -6's CPU to spend, since it spends it better than gzip -9 does (35,482 bytes against 37,432, same cost).

Brotli quality 4 compresses smaller and faster than gzip level 6

The 5 MB export tells the same story with the costs multiplied:

5 MB JSON export output ratio compress (median) decompress
gzip -1 1,148,812 4.42x 17.6 ms 6.40 ms
gzip -6 (default) 943,402 5.39x 39.2 ms 5.61 ms
gzip -9 908,756 5.59x 55.7 ms 5.76 ms
brotli q4 876,437 5.80x 20.2 – 25.9 ms 4.8 – 8.0 ms
brotli q5 823,294 6.17x 51.2 – 61.1 ms 6.87 ms
brotli q9 760,877 6.68x 162 – 173 ms 6.13 ms
brotli q10 680,797 7.46x 1,980 – 2,270 ms 6.74 ms
brotli q11 (default) 656,599 7.74x 5,191 – 6,639 ms 6.25 ms

Brotli q4 is 7.1% smaller than gzip -6 and 1.5 to 2.0 times as fast. Brotli q11 is 30.4% smaller than gzip -6 and takes 132 to 165 times as long. Ranges are given for brotli because its top levels were the only measurements that moved between process invocations, across five separate runs; gzip's did not.

Is brotli too slow for dynamic responses?

At the default quality, yes, and the failure mode is a queue rather than a stall. Node's async zlib calls run on the libuv threadpool — four threads unless you change UV_THREADPOOL_SIZE. Firing 32 concurrent compressions of the 200 KB body at it, three passes:

32 concurrent, 200 KB each total per request throughput worst event-loop lag
gzip -1 6.8 – 9.7 ms 0.21 – 0.30 ms 3,305 – 4,677 req/s 0.0 – 0.9 ms
gzip -6 15.1 – 18.9 ms 0.47 – 0.59 ms 1,695 – 2,121 req/s 1.1 – 5.3 ms
brotli q4 12.2 – 14.1 ms 0.38 – 0.44 ms 2,277 – 2,623 req/s 0.3 – 1.2 ms
brotli q5 20.0 – 26.8 ms 0.63 – 0.84 ms 1,196 – 1,596 req/s 1.0 – 1.4 ms
brotli q11 1,892 – 2,215 ms 59 – 69 ms 14 – 17 req/s 3.8 – 6.0 ms

The event loop stays responsive throughout — the async API keeps the work off it — so this is not a hung process. It is a compression queue with four servers and a two-second drain time, and every request behind it inherits that latency. A hundred-fold throughput collapse that leaves your event-loop metrics green is a nasty thing to diagnose at 2am.

If a reverse proxy is doing the compressing rather than your application — which is usually the better arrangement — the same rule applies to its config: set the quality, do not accept the default.

When is compression not worth it?

There is a clean piece of arithmetic for this. Compressing pays off when the transfer time you remove exceeds the CPU time you add:

bytes_saved / bandwidth  >  compression_time

Rearranged, every codec has a break-even link speed: bytes_saved × 8 ÷ compression_time. Below that speed, compress. Above it, you are spending CPU to make the response arrive later. Measured, in Mbit/s:

break-even link speed 2 KB response 200 KB list 5 MB export
gzip -1 436 1,854 1,755
gzip -6 405 867 843
gzip -9 402 624 594
brotli q4 244 1,415 1,579
brotli q5 260 644 634
brotli q9 204 409 232
brotli q11 6 8 7

Take a plausible link and read across. On 100 Mbit/s home fibre, the 200 KB raw body takes 16.0 ms on the wire (200,380 bytes ÷ 12.5 MB/s). Brotli q4 cuts that to 3.1 ms for 0.9 ms of CPU — a net 12.0 ms saved. Brotli q11 cuts it to 2.4 ms for 179 ms, so the response arrives 166 ms later than if you had sent it uncompressed. On a 1 Gbit/s link inside a datacentre, gzip -6 and brotli q5 both cross into negative territory here (−0.20 ms and −0.73 ms); only the fast levels still pay.

The other boundary is size, and it is lower than the folklore. gzip -6 and brotli q5 on small realistic JSON bodies:

raw gzip -6 ratio brotli q5 ratio
115 B 122 B 0.94x 103 B 1.12x
166 B 157 B 1.06x 135 B 1.23x
325 B 221 B 1.47x 189 B 1.72x
844 B 444 B 1.90x 384 B 2.20x
1,420 B 842 B 1.69x 754 B 1.88x
2,030 B 922 B 2.20x 816 B 2.49x

gzip only starts paying for itself between 115 and 166 bytes, where its 18 bytes of framing stop dominating; at 115 bytes it made the body larger. Brotli was still winning there, on a smaller frame plus its built-in static dictionary of common web text. So the usual advice to set a 1 KB minimum is not about ratio — it is about not bothering when the body fits in one packet anyway. At 100 Mbit/s the 2 KB response takes 0.152 ms to transmit uncompressed, less than the 1.467 ms brotli q11 spends thinking about it.

Does compressing an already-compressed payload cost you anything?

This is where a measurement contradicted me. I expected base64-of-a-PNG to be a pure waste of CPU. It is not.

A real 371,165-byte PNG, built in-process with node:zlib (a PNG's IDAT chunk is a zlib stream), base64-encoded into a JSON field:

input codec output ratio time
raw PNG, 371,165 B gzip -6 371,298 B 1.000x 4.95 ms
raw PNG, 371,165 B brotli q5 371,170 B 1.000x 0.50 ms
raw PNG, 371,165 B brotli q11 371,170 B 1.000x 144 ms
JSON + base64 PNG, 496,654 B gzip -6 373,895 B 1.328x 6.91 ms
JSON + base64 PNG, 496,654 B brotli q5 373,142 B 1.331x 2.38 ms
JSON + base64 PNG, 496,654 B brotli q11 372,070 B 1.335x 390 ms
plain JSON, 497,103 B gzip -6 92,928 B 5.349x 3.83 ms

Two findings. Compressing the raw binary is a strict loss: gzip -6 spent 4.95 ms to make it 133 bytes bigger, because there is nothing left to find and it must still emit a header and stored blocks. Brotli was gentler — five bytes of overhead and 0.50 ms at q5 — but gained nothing either.

Compressing the base64 is a real 1.33x win, and it is precisely the base64 tax coming back. Base64 spends 8 bits to carry 6, a 33.3% expansion, and that is pure redundancy any entropy coder recovers. The gzipped output, 373,895 bytes, is 1.007x the original PNG: compression got you back to the binary size and not one byte further. So leave compression on for JSON carrying base64 blobs — but the ratio in your metrics is not compression working, it is base64 being undone. The same payload as plain JSON compresses 5.35x. If much of your response body is base64, that gap is the argument for moving those blobs to their own endpoint, the same argument as not selecting columns you never read one layer down. And note the break-even: on that body gzip -6's is only 128 Mbit/s, so inside a datacentre even this is a loss.

One thing made no difference at all: BROTLI_PARAM_SIZE_HINT set to the exact input length changed the output by zero bytes at every quality, and BROTLI_PARAM_LGWIN: 24 made the 5 MB output slightly larger (876,615 against 876,437 at q4). Node already passes the size for one-shot buffer calls.

So what should you actually set?

Dynamic API responses: brotli quality 4, gzip -6 as the fallback, and a minimum size around 1 KB. Quality 4 is smaller and faster than gzip's default on every payload measured here, so there is no configuration in which gzip -6 beats it. Quality 5 if you would rather spend gzip -6's exact CPU budget on 8.5% fewer bytes. Never the brotli default; 14 requests per second is not a compression setting, it is an outage.

Static assets: brotli quality 11, at build time, written to disk. The 5 MB export costs about six seconds at q11 — but served a thousand times that is about 6 ms of amortised CPU per request, and each one carries 166,695 fewer bytes than q5 and 286,803 fewer than gzip -6. That is the trade quality 11 was designed for, and why it is the default: brotli assumes you are compressing a bundle once, not a response per request. The mistake is not the default, it is letting a dynamic path inherit it.

Check one thing first: whether your proxy is re-compressing what your application already compressed. It is common, and visible in one curl -I -H 'Accept-Encoding: br, gzip'.

Check it yourself

No dependencies, no network, no container, about six seconds. Save as compdemo.mjs and run node compdemo.mjs. It generates a realistic JSON list, builds a real PNG with node:zlib and base64s it into a second body, then reports ratio, compression time, decompression time and the break-even link speed for each codec, asserting the round trip on every one.

// compdemo.mjs -- gzip vs brotli on a realistic JSON API response.
// REALISTIC JSON, not random bytes: random data does not compress at all, and a
// benchmark built on it would recommend the wrong thing.
import zlib from 'node:zlib';

const C = zlib.constants;
const LINK_MBIT = 100;                       // the link we are optimising for
const rnd = (() => { let a = 20260831; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; })();
const pick = (a) => a[Math.floor(rnd() * a.length)];
const pad = (n, w) => String(n).padStart(w, '0');
const hex = (n) => Array.from({ length: n }, () => '0123456789abcdef'[Math.floor(rnd() * 16)]).join('');

const FIRST = ['ada', 'grace', 'linus', 'alan', 'radia', 'ken', 'mei', 'ravi', 'lena', 'pablo'];
const LAST = ['lovelace', 'hopper', 'torvalds', 'turing', 'perlman', 'thompson', 'tanaka', 'patel', 'novak', 'ortega'];
const CITY = ['Hanoi', 'Singapore', 'Bangkok', 'Tokyo', 'London', 'Berlin', 'Madrid', 'Toronto', 'Austin', 'Seattle'];
const STATUS = ['pending', 'paid', 'packed', 'shipped', 'delivered', 'refunded'];
const NOUN = ['kettle', 'lamp', 'desk', 'shelf', 'headset', 'mug', 'stool', 'speaker'];

const order = (i) => ({
  id: `${hex(8)}-${hex(4)}-${hex(4)}-${hex(4)}-${hex(12)}`,
  order_number: `ORD-${pad(100000 + i, 8)}`,
  status: pick(STATUS),
  created_at: new Date(Date.UTC(2025, 0, 1) + Math.floor(rnd() * 3e10)).toISOString(),
  customer: { email: `${pick(FIRST)}.${pick(LAST)}${Math.floor(rnd() * 900)}@example.com`, city: pick(CITY), segment: rnd() > 0.7 ? 'vip' : 'returning' },
  lines: Array.from({ length: 1 + Math.floor(rnd() * 4) }, (_, k) => ({
    line_id: `ln_${pad(i, 7)}_${k}`, sku: `SKU-${pad(Math.floor(rnd() * 99999), 5)}`,
    name: pick(NOUN), quantity: 1 + Math.floor(rnd() * 5),
    unit_price: Math.round(rnd() * 24000) / 100, fulfilled: rnd() > 0.3
  })),
  totals: { currency: 'USD', grand_total: Math.round(rnd() * 90000) / 100 }
});

const body = Buffer.from(JSON.stringify({
  data: Array.from({ length: 300 }, (_, i) => order(i)),
  pagination: { page: 3, per_page: 300, total: 48213 }
}));

// An already-compressed payload: a real PNG (its IDAT is a zlib stream), base64'd
// into a JSON field, exactly as an API returns a thumbnail.
const crcT = new Int32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcT[n] = c; }
const crc32 = (b) => { let c = -1; for (let i = 0; i < b.length; i++) c = crcT[(c ^ b[i]) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0; };
const chunk = (ty, d) => { const l = Buffer.alloc(4); l.writeUInt32BE(d.length); const bd = Buffer.concat([Buffer.from(ty), d]); const c = Buffer.alloc(4); c.writeUInt32BE(crc32(bd)); return Buffer.concat([l, bd, c]); };
const W = 300, H = 220, raw = Buffer.alloc(H * (1 + W * 3));
for (let y = 0; y < H; y++) { const o = y * (1 + W * 3); for (let x = 0; x < W; x++) { const p = o + 1 + x * 3; raw[p] = (Math.sin(x / 23) * 90 + 128 + (rnd() - 0.5) * 70) & 255; raw[p + 1] = (Math.cos(y / 31) * 90 + 128 + (rnd() - 0.5) * 70) & 255; raw[p + 2] = ((x * y) / 97 + (rnd() - 0.5) * 70) & 255; } }
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(W, 0); ihdr.writeUInt32BE(H, 4); ihdr[8] = 8; ihdr[9] = 2;
const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
const imgBody = Buffer.from(JSON.stringify({ data: { id: 'thumb_1', mime: 'image/png', encoding: 'base64', bytes: png.toString('base64') } }));

const med = (a) => { const s = [...a].sort((x, y) => x - y); return s[s.length >> 1]; };
const time = (f, reps) => { f(); const o = []; for (let i = 0; i < reps; i++) { const t = process.hrtime.bigint(); f(); o.push(Number(process.hrtime.bigint() - t) / 1e6); } return o; };

const codecs = [
  ['gzip -1', (b) => zlib.gzipSync(b, { level: 1 }), (b) => zlib.gunzipSync(b)],
  ['gzip -6', (b) => zlib.gzipSync(b, { level: 6 }), (b) => zlib.gunzipSync(b)],
  ['gzip -9', (b) => zlib.gzipSync(b, { level: 9 }), (b) => zlib.gunzipSync(b)],
  ['brotli q4', (b) => zlib.brotliCompressSync(b, { params: { [C.BROTLI_PARAM_QUALITY]: 4 } }), (b) => zlib.brotliDecompressSync(b)],
  ['brotli q5', (b) => zlib.brotliCompressSync(b, { params: { [C.BROTLI_PARAM_QUALITY]: 5 } }), (b) => zlib.brotliDecompressSync(b)],
  ['brotli q11', (b) => zlib.brotliCompressSync(b, { params: { [C.BROTLI_PARAM_QUALITY]: 11 } }), (b) => zlib.brotliDecompressSync(b)]
];

function table(label, buf, reps) {
  console.log(`\n== ${label}: ${buf.length} raw bytes ==`);
  console.log('codec         out B   ratio   comp ms   decomp ms   break-even link   ms saved @ ' + LINK_MBIT + ' Mbit/s');
  for (const [name, enc, dec] of codecs) {
    const out = enc(buf);
    if (dec(out).length !== buf.length) throw new Error('roundtrip failed: ' + name);
    const ct = med(time(() => enc(buf), reps));
    const dt = med(time(() => dec(out), reps));
    const saved = buf.length - out.length;
    const be = (saved * 8) / (ct / 1000) / 1e6;
    const net = (saved / ((LINK_MBIT * 1e6) / 8)) * 1000 - ct;
    console.log(name.padEnd(12) + String(out.length).padStart(8) + (buf.length / out.length).toFixed(2).padStart(8) + 'x' +
      ct.toFixed(3).padStart(10) + dt.toFixed(3).padStart(12) + (be.toFixed(0) + ' Mbit/s').padStart(18) +
      ((net >= 0 ? '+' : '') + net.toFixed(2) + ' ms').padStart(24));
  }
}

console.log(`node ${process.versions.node}  brotli ${process.versions.brotli}  ${process.arch} ${process.platform}`);
console.log(`break-even link = bytes saved x 8 / compression time. Slower link -> compress.`);
table('realistic JSON list', body, 15);
table('JSON with a base64 PNG (already compressed)', imgBody, 9);
console.log(`\nraw PNG was ${png.length} B; base64 made it ${png.toString('base64').length} B (+33%).`);
console.log(`gzip -6 on that body returns ${zlib.gzipSync(imgBody, { level: 6 }).length} B -- it wins back the base64 tax, and nothing else.`);

On the M3 laptop above that prints:

node 23.5.0  brotli 1.1.0  arm64 darwin
break-even link = bytes saved x 8 / compression time. Slower link -> compress.

== realistic JSON list: 165019 raw bytes ==
codec         out B   ratio   comp ms   decomp ms   break-even link   ms saved @ 100 Mbit/s
gzip -1        32256    5.12x     0.353       0.180       3008 Mbit/s               +10.27 ms
gzip -6        27704    5.96x     1.258       0.172        873 Mbit/s                +9.73 ms
gzip -9        26087    6.33x     2.173       0.152        511 Mbit/s                +8.94 ms
brotli q4      27591    5.98x     0.659       0.194       1669 Mbit/s               +10.34 ms
brotli q5      25947    6.36x     1.646       0.180        676 Mbit/s                +9.48 ms
brotli q11     21797    7.57x   162.380       0.241          7 Mbit/s              -150.92 ms

== JSON with a base64 PNG (already compressed): 259383 raw bytes ==
codec         out B   ratio   comp ms   decomp ms   break-even link   ms saved @ 100 Mbit/s
gzip -1       195367    1.33x     4.049       0.821        126 Mbit/s                +1.07 ms
gzip -6       195367    1.33x     4.014       0.813        128 Mbit/s                +1.11 ms
gzip -9       195367    1.33x     4.007       0.799        128 Mbit/s                +1.11 ms
brotli q4     195032    1.33x     1.145       0.713        450 Mbit/s                +4.00 ms
brotli q5     195072    1.33x     1.307       0.720        394 Mbit/s                +3.84 ms
brotli q11    194893    1.33x   248.440       1.527          2 Mbit/s              -243.28 ms

raw PNG was 194480 B; base64 made it 259308 B (+33%).
gzip -6 on that body returns 195367 B -- it wins back the base64 tax, and nothing else.

Read the ms saved column first: brotli q4 saves 10.34 ms on that body and brotli q11 loses 150.92 ms, same link, same data. Then set LINK_MBIT to 10 and watch q11 turn positive. That constant is the entire decision, and it is the one number nobody puts in their compression config.