A JSON API response of 200 KB takes a noticeable time to download on a mobile connection. Gzipped, the same response is about 20 KB. Compression is one of the cheapest performance wins in HTTP, and Node ships everything you need in node:zlib. It also comes with a CPU cost on the event loop, a negotiation protocol you have to follow, and a few cases where it makes things worse. The measurements below are from Node 22.23 on a laptop, so treat the timings as relative rather than absolute.
What compression buys, and what it costs
I generated a realistic product-list response, 2,000 objects with ids, SKUs, names and prices, and compressed it with each option (median of five runs, after a warm-up):
original JSON 214448 bytes
gzip level 1 24370 bytes 11.4% 0.4 ms
gzip level 6 (default) 21334 bytes 9.9% 1.2 ms
gzip level 9 20969 bytes 9.8% 3.1 ms
brotli quality 4 13838 bytes 6.5% 0.7 ms
brotli quality 11 9874 bytes 4.6% 243.1 ms
brotli default quality: 11Three things stand out. JSON compresses extremely well, because it repeats the same keys thousands of times. Going from gzip level 6 to 9 nearly tripled the time for a 2% smaller output, which is rarely worth it. And Brotli at quality 11 produced the smallest result but took about 240 ms, for one response, on the main thread.
That last number is the trap. Node’s Brotli default quality is 11 (zlib.constants.BROTLI_DEFAULT_QUALITY), because Brotli was designed with static assets in mind: compress app.js once at build time and serve the result millions of times. If you call createBrotliCompress() with no options in a request handler, every request pays that cost. For dynamic responses, quality 4 or 5 is the usual choice. Here quality 4 beat gzip’s default on both size and speed.
Where does the work run? The streaming APIs (createGzip, createBrotliCompress) and the callback forms (zlib.gzip) do their compression on libuv’s thread pool, which keeps the event loop free but shares four threads with fs, dns.lookup and crypto.scrypt. The *Sync functions run on the main thread and block it; they’re fine in a build script, not in a server.
Data that doesn’t compress
Compression works by finding repetition. Formats that are already compressed have none left. I gzipped 200,000 random bytes as a stand-in for a JPEG or a zip file:
already-compressed-like data: 200000 -> 200083 bytesIt got 83 bytes bigger, and you paid CPU for the privilege. The same applies to JPEG, PNG, WebP, AVIF, MP4, WOFF2 fonts, PDF (mostly) and anything zipped. Compress text types: HTML, CSS, JavaScript, JSON, XML, SVG, plain text.
Tiny bodies lose too:
tiny body: 11 -> 31 bytes gzipA gzip stream has a fixed header and trailer of about 18 bytes, so {"ok":true} nearly triples. Most servers skip anything under about 1 KB, which fits in a single network packet anyway.
Negotiating the encoding
The client says what it can decode in Accept-Encoding. Browsers send something like gzip, deflate, br, zstd. Node’s own fetch sent a header that got gzip back from the server below. A client may also rank options with quality values (br;q=0, gzip;q=0.8 means “not Brotli, gzip is fine”). The server picks one, compresses, and labels the response with Content-Encoding. If the client offered nothing you support, send the body uncompressed. Never compress for a client that didn’t ask: it will hand gzip bytes to code expecting JSON.
The response also needs Vary: Accept-Encoding. Without it, a CDN or proxy can cache the Brotli version and serve it to a client that only understands gzip, or cache the uncompressed version and serve that to everyone. It’s the most commonly forgotten header in hand-rolled compression.
Here’s a complete sender with those rules:
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg\+xml)/;
const MIN_BYTES = 1024;
function chooseEncoding(header = '') {
const offered = new Map();
for (const part of header.split(',')) {
const [name, ...params] = part.trim().toLowerCase().split(';');
const q = params.find((p) => p.trim().startsWith('q='));
offered.set(name, q ? Number(q.trim().slice(2)) : 1);
}
if (offered.get('br') > 0) return 'br';
if (offered.get('gzip') > 0) return 'gzip';
return null;
}
function compressor(encoding) {
return encoding === 'br'
? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
: zlib.createGzip({ level: 6 });
}
async function send(req, res, type, body) {
res.setHeader('Content-Type', type);
res.setHeader('Vary', 'Accept-Encoding');
const encoding = COMPRESSIBLE.test(type) && body.length >= MIN_BYTES
? chooseEncoding(req.headers['accept-encoding'])
: null;
if (!encoding) {
res.setHeader('Content-Length', body.length);
return res.end(body);
}
res.setHeader('Content-Encoding', encoding);
await pipeline(Readable.from([body]), compressor(encoding), res);
}Some decisions in there are easy to get wrong:
- No
Content-Lengthon compressed responses. The compressed size isn’t known until compression finishes, so Node falls back to chunked transfer encoding. Setting the uncompressed length while sending compressed bytes makes clients wait for data that never arrives, or truncate the body. pipeline, not.pipe(). If the client disconnects halfway,pipelinedestroys the compressor and the source and rejects with the error. With.pipe(), a failure on one side can leave the other open and leak memory or a file descriptor. The handler catches the rejection and destroys the response.- The negotiation is simplified. It ignores
*andidentity;q=0. For production, thecompressionmiddleware (Express) or@fastify/compresshandle the full grammar, thresholds and content-type filtering for you.
Requesting each path with different headers, and counting the body bytes received:
/ AE=null -> encoding=none, 96448 bytes on the wire, vary=Accept-Encoding
/ AE="gzip, deflate" -> encoding=gzip, 11147 bytes on the wire, vary=Accept-Encoding
/ AE="gzip, deflate, br, zstd" -> encoding=br, 3822 bytes on the wire, vary=Accept-Encoding
/ AE="br;q=0, gzip;q=0.8" -> encoding=gzip, 11147 bytes on the wire, vary=Accept-Encoding
/small AE="gzip, br" -> encoding=none, 11 bytes on the wire, vary=Accept-Encoding
/photo AE="gzip, br" -> encoding=none, 50000 bytes on the wire, vary=Accept-Encoding
fetch(): gzip -> 96448 chars after automatic decoding(This demo used a slightly smaller product list, 96 KB.) No header means no compression; a browser-style header gets Brotli; the explicit br;q=0 is respected; the small body and the JPEG are sent as they are. The last line shows that fetch decompresses transparently, which is why you rarely notice compression from the client side, and why a bug that sends Content-Encoding: gzip with uncompressed bytes shows up as a decoding error in the client rather than in your server logs.
Node 22.15 also added experimental Zstandard support (zlib.createZstdCompress), and current browsers advertise zstd. It’s worth watching, but gzip and Brotli are the safe defaults today.
Where compression should happen
In many deployments the answer is “not in Node”. A CDN or reverse proxy (Nginx, Cloudflare, a cloud load balancer) can compress responses on its own CPUs, cache the compressed variants, and serve precompressed static files. Static assets are best compressed once at build time with Brotli 11 and gzip 9, with the server picking the matching .br or .gz file. In-process compression earns its place when nothing in front of Node does it, or for dynamic responses the proxy doesn’t handle. If you do both, make sure you don’t double-compress: a proxy compressing an already-compressed body produces gibberish for the client unless it checks Content-Encoding.
One security note interviewers sometimes raise: compressing responses over HTTPS that contain both a secret (a CSRF token) and attacker-influenced text in the same body enables the BREACH attack, where response sizes leak the secret. The usual mitigations are to keep such secrets out of compressed bodies or mask them per request, rather than to disable compression everywhere.
Interview exercise: the slow API after enabling compression
“After adding compression to our Node API, median latency improved for mobile users but p99 latency on the server got worse, and CPU went from 30% to 70%. The change was res.pipe(zlib.createBrotliCompress()) wrapped around every response. What’s going on, and how would you fix it without losing the bandwidth savings?”
Answer
createBrotliCompress() with no options uses quality 11, which is meant for one-off compression of static assets. In my measurement it took about 240 ms for a 200 KB JSON body. Every response now pays that, on libuv’s four-thread pool, so concurrent requests queue behind each other (and behind any fs or DNS work), which shows up as p99 latency and high CPU. “Every response” also means images, already-compressed files and tiny JSON bodies are compressed for no benefit, and the code as described doesn’t check Accept-Encoding, so it may send Brotli to clients that never asked for it.
I’d set Brotli quality to around 4, which in my test still beat default gzip on size and was faster, or use gzip level 6. I’d negotiate properly from Accept-Encoding, add Vary: Accept-Encoding, skip non-text content types and bodies under about 1 KB, and stream through pipeline so disconnects clean up. Better still, I’d check whether the CDN or load balancer in front of the API can compress, and move static assets to build-time compression with precompressed files. Then I’d compare CPU and p99 before and after with the same load test, plus response sizes for a typical payload, so the trade-off is measured rather than guessed.