Ch. 6 · Node.js

Node.js Response Compression with zlib

Stream gzip or Brotli responses with node:zlib, negotiate Accept-Encoding, send Vary, and skip tiny bodies and files already compressed.

~7 min readintermediateupdated Oct 19, 2026

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

Three 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 bytes
Text

It 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 gzip
Text

A 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);
}
JavaScript

Some decisions in there are easy to get wrong:

  • No Content-Length on 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, pipeline destroys 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 * and identity;q=0. For production, the compression middleware (Express) or @fastify/compress handle 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
Text

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

Further reading

More in Node.js

read ✓Node.js · mid

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readread →
read ✓Node.js · mid

Node.js Request Body Validation and Limits

Read a JSON request body in Node.js with a byte limit that also catches chunked uploads, then validate its shape and return 413, 415, 400 or 422.

~6 min readread →
esc