Compression with zlib

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. The node:zlib module compresses and decompresses data with the DEFLATE family (gzip, zlib "deflate", raw deflate), with Brotli and, experimentally, with Zstandard. The module as a whole is Stability 2 (Stable); the Zstandard classes are Stability 1 (Experimental); the ZIP archive API added in Node.js 26.8.0 is Stability 1.0 (Early development); the iterable transforms in node:zlib/iter are Stability 1 (Experimental) and need a flag. Compression is built on streams, so read Streams first if pipeline() is unfamiliar; this page owns what is specific to compression: choosing a format, the one-shot and streaming entry points, safe decompression, HTTP content encoding and the CPU cost. The running scenario is the Bookshelf application that grows across this section, whose architecture is described in Introduction & Architecture.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

gzip, deflate, raw deflate: createGzip(), gzip(), gzipSync() and the other classes and convenience methods

2 Stable (node:zlib)

Present since the early 0.x releases; Gunzip has supported concatenated gzip members since v5.9.0 and fails on trailing garbage and on truncated input since v6.0.0 and v5.0.0.

Brotli: createBrotliCompress(), brotliCompress() and friends

2 Stable (node:zlib)

Added in v11.7.0 and v10.16.0.

Zstandard: createZstdCompress(), zstdCompress() and friends

1 Experimental

Added in v23.8.0 and v22.15.0, so it exists on all three lines but is Experimental on all three.

zlib.crc32()

Stable (node:zlib)

Added in v22.2.0 and v20.15.0.

rejectGarbageAfterEnd option

Stable (node:zlib option)

Added in v26.5.0 and v24.19.0 (the History of the Options class in the latest and the Node.js 24 docs); absent from the Node.js 22 docs. Observed to work on 24.21.0 and to be ignored on 24.16.0 and 22.23.3, where trailing data after the first stream is not rejected.

ZIP archive API: ZipFile, ZipBuffer, ZipEntry, createZipArchive(), zipFiles(), setMaxZipContentSize()

1.0 Early development

Added in v26.8.0. Using any of it emits an experimental warning the first time. Absent on Node.js 24 and 22 (observed: ZipFile is undefined).

node:zlib/iter (compressGzip(), decompressGzip(), …​)

1 Experimental

Added in v25.9.0; available only with --experimental-stream-iter, together with node:stream/iter. Absent on Node.js 24 and 22.

CompressionStream and DecompressionStream (Web Streams)

Stable globals

The deflate-raw format was added in v21.2.0 and v20.12.0; the brotli format in v24.7.0 and v22.20.0. There is no zstd format (observed: TypeError).

Choosing a format

Every format answers the same question (how few bytes can this data take?) with a different trade between ratio, speed, memory and compatibility. The names are confusing because three of them share one algorithm.

Table 2. The formats node:zlib can produce
Format Classes and methods Typical use

gzip

createGzip() / createGunzip(), gzip() / gunzip(), gzipSync() / gunzipSync()

Files ending in .gz, HTTP Content-Encoding: gzip, the default choice for compatibility. Adds a header and a CRC-32 trailer (RFC 1952).

deflate ("zlib" format)

createDeflate() / createInflate(), deflate() / inflate() and the Sync variants

The zlib wrapper around DEFLATE with an Adler-32 check. HTTP Content-Encoding: deflate means this wrapper, not raw deflate.

raw deflate

createDeflateRaw() / createInflateRaw(), deflateRaw() / inflateRaw()

No header and no checksum; for containers that supply their own framing, such as ZIP entries.

unzip

createUnzip(), unzip(), unzipSync()

Decompression only: detects gzip or deflate from the header. It does not read ZIP archives despite the name.

Brotli

createBrotliCompress() / createBrotliDecompress(), brotliCompress() / brotliDecompress() and the Sync variants

Best ratio for text (HTTP Content-Encoding: br, static assets compressed ahead of time), but slow at high quality settings.

Zstandard

createZstdCompress() / createZstdDecompress(), zstdCompress() / zstdDecompress() and the Sync variants

Fast with a good ratio; HTTP Content-Encoding: zstd. Experimental in Node.js, so treat the API as subject to change.

The one-shot example below compresses the same 100 KB JSON document with each format. The sizes are what this run printed (observed, and identical on Node.js 26, 24 and 22); they depend on the data and on the zlib and Brotli versions bundled with each release, so measure your own payloads before choosing.

Compressing one buffer with every DEFLATE-family format and Brotli
import { gzipSync, gunzipSync, deflateSync, deflateRawSync, unzipSync,
  brotliCompressSync, brotliDecompressSync, gzip, gunzip, constants } from 'node:zlib';
import { promisify } from 'node:util';

const input = Buffer.from(JSON.stringify(Array.from({ length: 2000 }, (_, i) => ({ isbn: `978-${i}`, title: `Book ${i}`, rating: i % 5 + 1 }))));
console.log({
  raw: input.length,
  deflate: deflateSync(input).length,
  deflateRaw: deflateRawSync(input).length,
  gzip: gzipSync(input).length,
  brotli: brotliCompressSync(input).length,
  brotliText: brotliCompressSync(input, { params: {
    [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
    [constants.BROTLI_PARAM_QUALITY]: 4,
    [constants.BROTLI_PARAM_SIZE_HINT]: input.length } }).length,
});
console.log(gunzipSync(gzipSync(input)).equals(input), brotliDecompressSync(brotliCompressSync(input)).equals(input));
console.log(unzipSync(gzipSync('hi')).toString(), unzipSync(deflateSync('hi')).toString());

const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
console.log((await gunzipAsync(await gzipAsync('async round trip'))).toString());
Output
{
  raw: 99781,
  deflate: 10439,
  deflateRaw: 10433,
  gzip: 10451,
  brotli: 4349,
  brotliText: 3388
}
true true
hi hi
async round trip

The raw, deflate and gzip sizes differ only by the header and trailer bytes (six and eighteen bytes here). The Brotli options map onto the zlib ones: the zlib level corresponds to BROTLI_PARAM_QUALITY, and windowBits to BROTLI_PARAM_LGWIN. The default Brotli quality is the maximum, which is slow for on-the-fly compression of responses; the second Brotli figure above uses quality 4 with the text mode and a size hint and is still smaller than gzip. The Zstandard options map the same way: level to ZSTD_c_compressionLevel and windowBits to ZSTD_c_windowLog.

Streams: the normal way to compress files and network data

The classes returned by createGzip(), createGunzip(), createBrotliCompress() and the others are Transform streams: write plain bytes in, read compressed bytes out. Because they apply backpressure like any other stream, they let you compress a file much larger than memory with a constant footprint. Always connect them with pipeline() from node:stream/promises: it forwards errors from every stage and destroys all of them on failure, which .pipe() does not (see Streams).

Gzip a file and read it back through a pipeline (the Bookshelf CSV export)
import { createReadStream, createWriteStream } from 'node:fs';
import { stat, writeFile, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { createGzip, createGunzip } from 'node:zlib';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const csv = join(dir, 'books.csv');
const rows = ['isbn,title,rating'];
for (let i = 0; i < 50_000; i++) rows.push(`978-${i},Book number ${i},${i % 5 + 1}`);
await writeFile(csv, rows.join('\n'));

// compress
await pipeline(createReadStream(csv), createGzip({ level: 9 }), createWriteStream(`${csv}.gz`));
// decompress and count bytes without keeping them
let bytes = 0;
await pipeline(
  createReadStream(`${csv}.gz`),
  createGunzip(),
  async function* (source) { for await (const chunk of source) bytes += chunk.length; },
);
console.log({ original: (await stat(csv)).size, gzipped: (await stat(`${csv}.gz`)).size, inflated: bytes });
Output
{ original: 1477797, gzipped: 253458, inflated: 1477797 }

The same shape imports a compressed upload. The next example gunzips a stream, reads it line by line with node:readline, and shows that a corrupt upload makes the pipeline reject (with Z_DATA_ERROR) instead of crashing the process.

Reading a gzipped CSV line by line, and rejecting corrupt input
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { createGzip, createGunzip } from 'node:zlib';

const dir = await mkdtemp(join(tmpdir(), 'import-'));
const file = join(dir, 'reviews.csv.gz');
await pipeline(Readable.from(['isbn,rating\n', '978-1,5\n', '978-2,4\n']), createGzip(), createWriteStream(file));

const lines = createInterface({ input: createReadStream(file).pipe(createGunzip()), crlfDelay: Infinity });
for await (const line of lines) console.log(line);

// a corrupt upload rejects the pipeline instead of crashing
try {
  await pipeline(Readable.from([Buffer.from('not gzip at all')]), createGunzip(), async function* (s) { for await (const _ of s); });
} catch (e) { console.log('rejected with', e.code); }
Output
isbn,rating
978-1,5
978-2,4
rejected with Z_DATA_ERROR

The first half of that example uses .pipe() only to feed readline, which has no error path of its own; in production code build the chain with pipeline() and a generator stage, as in the previous example. See Implementing Custom Streams for the CSV parser stage and the file system page for the file streams.

Table 3. Stream options worth knowing
Option What it does

level

Compression effort (zlib: 0 to 9; Brotli: BROTLI_PARAM_QUALITY; Zstandard: ZSTD_c_compressionLevel). It affects speed most; higher means smaller output and more CPU.

windowBits, memLevel

Memory against ratio. The documented formula for deflate memory is (1 << (windowBits + 2)) + (1 << (memLevel + 9)) bytes, about 256 KiB with the defaults of 15 and 8; windowBits: 14 with memLevel: 7 halves it at some cost in ratio. Inflate needs 1 << windowBits bytes, plus one output slab of chunkSize bytes.

chunkSize

Size of the output slab, 16 KiB by default.

flush, finishFlush

The flush mode between writes and for the last chunk; used below to read truncated data.

maxOutputLength

A ceiling on the output of the convenience methods (not of streams); the default is buffer.kMaxLength. Set it whenever the input is untrusted.

dictionary

A preset dictionary for deflate and inflate (and, since v26.7.0, a TypedArray, DataView or ArrayBuffer for Zstandard).

info

Return { buffer, engine } from the convenience methods instead of a bare buffer.

rejectGarbageAfterEnd

Fail when more input follows the end of the compressed stream, including further gzip members (see the safety section).

One-shot calls: callback, promise and sync

For small, already-buffered data the convenience methods are shorter than a pipeline. Each takes a Buffer, TypedArray, DataView, ArrayBuffer or string, an optional options object and a callback, and every method has a Sync counterpart that takes the same arguments minus the callback. The asynchronous versions run on the libuv thread pool; the synchronous ones run on the main thread and block the event loop for the whole operation. util.promisify() turns the callback form into a promise, as gzipAsync did in the format example above. There is no node:zlib/promises module (observed: the import fails; the docs list none).

Pick by data size, not by habit. A few kilobytes (a cached JSON response, a session value) is fine either way. Anything larger, and any code that runs per request, should use the asynchronous or streaming form.

Pass one-shot calls through maxOutputLength
import { gzipSync, gunzipSync } from 'node:zlib';

const bomb = gzipSync(Buffer.alloc(50 * 1024 * 1024));
console.log('bomb compressed to', bomb.length, 'bytes');
try { gunzipSync(bomb, { maxOutputLength: 1024 * 1024 }); } catch (e) { console.log('bomb:', e.code); }
Output
bomb compressed to 50988 bytes
bomb: ERR_BUFFER_TOO_LARGE

Decompressing untrusted data safely

Compression ratios of a thousand to one are normal for repetitive data, so a 50 KB upload can inflate to 50 MB: a decompression bomb. Three rules keep the process alive.

  • Bound the output. Pass maxOutputLength to the convenience methods, as above. For streams, count bytes in a transform stage and destroy the stream when a limit is passed. Do the same for Zstandard and Brotli.

  • Bound the input and the concurrency, with request body limits (see Server Timeouts, Limits & Hardening) and a cap on simultaneous decompressions.

  • Treat errors as expected: truncated, corrupt and over-long input all raise errors that you handle at the boundary.

Errors the gzip decoder raises, and how to opt out of one of them
import { gzipSync, gunzipSync, constants } from 'node:zlib';

const good = gzipSync('The quick brown fox jumps over the lazy dog. '.repeat(50));
const truncated = good.subarray(0, good.length - 12);
try { gunzipSync(truncated); } catch (e) { console.log('truncated:', e.code, e.message); }
// inspect the beginning of a file you know is incomplete
const partial = gunzipSync(truncated, { finishFlush: constants.Z_SYNC_FLUSH });
console.log('partial output bytes:', partial.length);

try { gunzipSync(Buffer.from('definitely not gzip')); } catch (e) { console.log('garbage:', e.code, e.errno); }

// concatenated members are decoded as one stream
const two = Buffer.concat([gzipSync('one,'), gzipSync('two')]);
console.log(gunzipSync(two).toString());
// ...unless you refuse anything after the first member
try { gunzipSync(two, { rejectGarbageAfterEnd: true }); } catch (e) { console.log('second member:', e.code); }
Output (Node.js 26 and 24)
truncated: Z_BUF_ERROR unexpected end of file
partial output bytes: 1594
garbage: Z_DATA_ERROR -3
one,two
second member: ERR_TRAILING_JUNK_AFTER_STREAM_END

On Node.js 22.23.3 (and 24 releases before 24.19.0) the last line prints nothing, because the option is ignored and the second member decodes (observed). Reading a truncated stream with Z_SYNC_FLUSH removes the integrity check, so you cannot tell an early end from a complete file; validate the result yourself. The documented equivalents are BROTLI_OPERATION_FLUSH for Brotli and ZSTD_e_flush for Zstandard.

Zstandard (experimental)

Zstandard compresses much faster than gzip at comparable ratios and decompresses very quickly, and the HTTP content codings that node:zlib documents include zstd. The node:zlib classes mirror the Brotli ones, take a params object keyed by ZSTD_c_* constants and accept a dictionary, pledgedSrcSize and rejectGarbageAfterEnd. The API has been Stability 1 (Experimental) since it was added in v23.8.0 and v22.15.0 and still is on Node.js 26, so prefer gzip or Brotli for formats you must read back years from now and keep Zstandard behind a small wrapper that you can adjust.

One-shot Zstandard compression with a checksum
import zlib from 'node:zlib';

const input = Buffer.from('zstd '.repeat(10_000));
const c = zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 10, [zlib.constants.ZSTD_c_checksumFlag]: 1 } });
console.log(c.length, zlib.zstdDecompressSync(c).equals(input));
Output (Node.js 26, 24 and 22)
27 true

crc32 checksums

zlib.crc32(data[, value]) returns the 32-bit CRC of a string (encoded as UTF-8), Buffer, TypedArray or DataView, as an unsigned integer. Pass the previous result as value to checksum data in pieces. CRC-32 detects accidental corruption; it is not a cryptographic hash and must not be used to authenticate data (use node:crypto for that).

Checksumming data incrementally
import { crc32 } from 'node:zlib';

let crc = crc32('hello');
console.log(crc, crc32('world', crc), crc32('helloworld'));
console.log(crc32(Buffer.from('hello', 'utf16le')));
Output (Node.js 26, 24 and 22)
907060870 4192936109 4192936109
1427272415

The last line is the subtle part: a library that computes the CRC over str.charCodeAt() values matches crc32(Buffer.from(str, 'utf16le')), not crc32(str). Before crc32() existed (added in v22.2.0 and v20.15.0), tutorials installed an npm package for this.

ZIP archives (experimental, Node.js 26.8.0 and later)

Node.js 26.8.0 added a ZIP archive API to node:zlib. It is Stability 1.0 (Early development), emits an experimental warning on first use and does not exist on Node.js 24 or 22, so third-party libraries remain the portable choice for now. It has three layers:

  • ZipEntry: one file or directory, created with ZipEntry.create() (in memory), ZipEntry.createStream() (from a stream) or ZipEntry.createSymlink(), and read with content() or the bounded-memory contentIterator(). Entries are stored with the method 'deflate' (the default), 'store' or 'zstd' and carry a CRC-32 that content() verifies.

  • ZipBuffer and ZipFile: a lazily read, editable view of an archive that is already in memory or on disk. ZipFile.open() reads only the central directory; opened with { writable: true } it can add() and delete() in place, but those edits are not crash-atomic, so write to a copy when durability matters.

  • Writers: createZipArchive(entries) serialises entries to a Readable, and zipFiles([[sourcePath, entryName], …​]) builds an archive from files on disk, streaming each file without buffering it. Directories are not walked recursively.

Every asynchronous method has a Sync counterpart that blocks the event loop. setMaxZipContentSize() and getMaxZipContentSize() control the ceiling content() applies to a member’s declared size before allocating anything (256 MiB by default), a guard against ZIP bombs; the streaming readers are not affected.

Writing and reading a ZIP with the experimental API (Node.js 26.8.0 or later)
import { ZipEntry, ZipFile, ZipBuffer, createZipArchive, zipFiles, getMaxZipContentSize } from 'node:zlib';
import { createWriteStream } from 'node:fs';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';

const dir = await mkdtemp(join(tmpdir(), 'zip-'));
await writeFile(join(dir, 'review.txt'), 'Great book. '.repeat(100));
const archive = join(dir, 'export.zip');
await pipeline(
  zipFiles([[join(dir, 'review.txt'), 'reviews/review.txt']]),
  createWriteStream(archive),
);
const zip = await ZipFile.open(archive);
try {
  for (const [name, pending] of zip.entries()) {
    const e = await pending;
    console.log(name, e.size, e.compressedSize, e.method, e.crc32);
  }
  const entry = await zip.get('reviews/review.txt');
  console.log((await entry.content({ maxSize: 1024 * 1024 })).toString().slice(0, 22));
} finally { await zip.close(); }
console.log(getMaxZipContentSize());

const entries = [await ZipEntry.create('hello.txt', Buffer.from('Hello, world!'))];
const hello = await new ZipBuffer(Buffer.concat(await Array.fromAsync(createZipArchive(entries)))).get('hello.txt');
console.log((await hello.content()).toString());
Output (Node.js 26.10.0, after an ExperimentalWarning on standard error)
reviews/review.txt 1200 25 8 3854544580
Great book. Great book
268435456
Hello, world!

The 8 printed for e.method is the raw ZIP compression method: the documentation lists 0 for stored, 8 for deflate and 93 for Zstandard. Do not confuse it with the creation option method, which takes the strings 'deflate', 'store' or 'zstd'.

HTTP content encoding

HTTP negotiates compression with two headers. The client sends Accept-Encoding with the codings it can decode (br, gzip, optionally weighted with q= values); the server answers with Content-Encoding naming the coding it applied, and with Vary: Accept-Encoding so that caches keep one copy per coding. The registered names that node:zlib implements are gzip, deflate, br and zstd.

On the server side the rules are: compress only compressible types (text, JSON, SVG; not JPEG, PNG, MP4 or already-compressed archives), always set Vary, never apply a coding the client did not accept, and, because compressing on every request is expensive, cache the compressed bytes. The example below compresses the Bookshelf catalogue once per coding at start-up, honours q=0, and falls back to the identity encoding. Its pickEncoding() is a deliberately small parser; a production server uses a conformant one or puts a reverse proxy in front (see Server Timeouts, Limits & Hardening).

A server that serves pre-compressed Brotli or gzip variants with Vary, and a client that checks the result
import { createServer, get } from 'node:http';
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
import { once } from 'node:events';

const catalogue = Buffer.from(JSON.stringify(Array.from({ length: 5000 }, (_, i) => ({ isbn: `978-${i}`, title: `Book ${i}` }))));
// compress once, serve many times
const variants = new Map([
  ['br', brotliCompressSync(catalogue, { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } })],
  ['gzip', gzipSync(catalogue)],
]);

function pickEncoding(header = '') {
  const accepted = new Map();
  for (const part of header.split(',')) {
    const [name, ...params] = part.trim().split(';');
    const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
    accepted.set(name.trim().toLowerCase(), q ? Number(q.slice(2)) : 1);
  }
  for (const name of ['br', 'gzip']) if ((accepted.get(name) ?? accepted.get('*') ?? 0) > 0) return name;
  return 'identity';
}

const server = createServer((req, res) => {
  const encoding = pickEncoding(req.headers['accept-encoding']);
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Vary', 'Accept-Encoding');
  const body = encoding === 'identity' ? catalogue : variants.get(encoding);
  if (encoding !== 'identity') res.setHeader('Content-Encoding', encoding);
  res.setHeader('Content-Length', body.length);
  res.end(body);
});
server.listen(0);
await once(server, 'listening');
const url = `http://127.0.0.1:${server.address().port}/books`;

for (const ae of ['br, gzip', 'gzip', 'gzip;q=0, identity']) {
  const r = await fetch(url, { headers: { 'Accept-Encoding': ae } });
  const text = await r.text();
  console.log(ae, '->', r.headers.get('content-encoding'), r.headers.get('content-length'), text.length);
}
// http.get does not decompress: you see the bytes on the wire
get(url, { headers: { 'Accept-Encoding': 'gzip' } }, (res) => {
  let n = 0; res.on('data', (c) => { n += c.length; });
  res.on('end', () => { console.log('raw http.get bytes', n, res.headers['content-encoding']); server.close(); });
});
Output (Node.js 26, 24 and 22)
br, gzip -> br 8764 197781
gzip -> gzip 23768 197781
gzip;q=0, identity -> null 197781 197781
raw http.get bytes 23768 gzip

fetch() (Undici) decodes the response for you, which is why text.length is the full 197,781 characters while content-length is the compressed size (observed), and it sends its own Accept-Encoding header when you do not set one, so a test that omits the header still receives a compressed response (observed: gzip). With http.get() and http.request() you receive the compressed bytes and decode them yourself, choosing the decoder from content-encoding: createGunzip(), createInflate(), createBrotliDecompress() or createZstdDecompress(); createUnzip() handles both gzip and deflate. See fetch & HTTP Clients and HTTP Server Fundamentals.

For responses that cannot be cached, such as a live stream of events, compress through a stream and flush after each message. flush() makes zlib emit what it has buffered so far, at a small cost in ratio. The wrong choice here is the usual cause of "my compressed event stream arrives in one lump".

Flushing a compression stream so that partial data becomes readable
import { createGzip, createGunzip } from 'node:zlib';

const gz = createGzip();
const gun = createGunzip();
gz.pipe(gun);
gun.on('data', (c) => console.log('received', JSON.stringify(c.toString())));
gz.write('tick 1\n', () => {
  console.log('written (probably still buffered inside zlib)');
  gz.flush(() => { gz.write('tick 2\n', () => gz.flush(() => gz.end())); });
});
Output (Node.js 26, 24 and 22)
written (probably still buffered inside zlib)
received "tick 1\n"
received "tick 2\n"

The CPU cost: thread pool and the event loop

Compression is CPU work, and where it runs matters. The documentation states that all zlib APIs except those that are explicitly synchronous use the Node.js internal thread pool, so compressing a large buffer with gzip() does not block the JavaScript thread, but it does occupy one of the libuv pool’s threads (four by default, set with the UV_THREADPOOL_SIZE environment variable). That pool is shared with fs, dns.lookup() and crypto; see The Event Loop. Two consequences follow.

  • Synchronous calls (gzipSync() and all Sync methods) run on the main thread and stall every other request while they run. The example below shows a timer that should fire every 10 ms being delayed by about 184 ms during a synchronous gzip of 16 MiB of random data, and not at all during the asynchronous call (observed on one machine, Node.js 26; the numbers depend on hardware).

  • Many simultaneous asynchronous calls queue behind the pool, and each zlib object holds its own memory. The documentation warns that creating tens of thousands of zlib objects at once can fragment memory and recommends caching compressed results.

Measuring the delay a synchronous gzip causes to a timer
import { gzip, gzipSync } from 'node:zlib';
import { randomBytes } from 'node:crypto';

const data = randomBytes(16 * 1024 * 1024);
let maxGap = 0, last = performance.now();
const timer = setInterval(() => { const now = performance.now(); maxGap = Math.max(maxGap, now - last - 10); last = now; }, 10);
await new Promise((r) => setTimeout(r, 50));
maxGap = 0;
gzipSync(data, { level: 9 });
await new Promise((r) => setTimeout(r, 30));
console.log('sync: worst timer delay', Math.round(maxGap), 'ms');
maxGap = 0; last = performance.now();
await new Promise((r) => gzip(data, { level: 9 }, r));
console.log('async: worst timer delay', Math.round(maxGap), 'ms');
clearInterval(timer);
Output (observed, Node.js 26, one machine)
sync: worst timer delay 184 ms
async: worst timer delay 0 ms

The thread pool also limits parallelism. Eight concurrent gzip() calls on 8 MiB of random data took (observed, ten-core laptop, Node.js 26) about 540 ms with UV_THREADPOOL_SIZE=1, 220 ms with 4 (the default gives similar results) and 176 ms with 8. Set the variable in the environment before the process starts, keep it near the core count, and remember that pool threads are also used by file and crypto work.

Timing parallel gzip calls against the pool size
import { gzip } from 'node:zlib';
import { promisify } from 'node:util';
import { randomBytes } from 'node:crypto';

const gz = promisify(gzip);
const data = randomBytes(8 * 1024 * 1024);
const t = performance.now();
await Promise.all(Array.from({ length: 8 }, () => gz(data, { level: 9 })));
console.log(`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? 'default'}: ${Math.round(performance.now() - t)} ms for 8 parallel gzip calls`);
Run with different pool sizes
UV_THREADPOOL_SIZE=1 node pool.js
UV_THREADPOOL_SIZE=4 node pool.js
UV_THREADPOOL_SIZE=8 node pool.js

A practical policy: use streaming pipeline() for large bodies; compress static assets and rarely-changing responses once, at build time or on first use, and cache them; use a fast level (gzip 4 to 6, Brotli 4 to 5) for responses compressed on the fly; keep the synchronous methods for start-up code and command-line tools; and when compression dominates a profile, move it out of the main process (see Don’t Block the Event Loop).

Web Streams and iterable compression

zlib is not the only entry point. The Web Streams CompressionStream and DecompressionStream globals compress with the formats gzip, deflate, deflate-raw and brotli, and work the same way in browsers, which is useful for code shared with a front end; they have no Zstandard format. They are covered with the other Web Streams in Web Streams & Interop.

Compressing with Web Streams
const compressed = new Blob(['web streams compress too. '.repeat(100)])
  .stream()
  .pipeThrough(new CompressionStream('gzip'));
const bytes = new Uint8Array(await new Response(compressed).arrayBuffer());
console.log('gzip bytes:', bytes.length);
const text = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
console.log(text.length);
Output (Node.js 26, 24 and 22)
gzip bytes: 66
2600

Node.js 26 also ships node:zlib/iter (added in v25.9.0, Stability 1 Experimental), compression transforms for the experimental node:stream/iter API. It is available only with the --experimental-stream-iter flag, which Node.js 24 and 22 do not know. The asynchronous variants run on the thread pool; the Sync variants run on the main thread. Their defaults differ from node:zlib: gzip and deflate default to level 4 and memLevel 9, and Brotli to quality 6, tuned for streaming throughput.

Round trip with the iterable transforms (Node.js 26 with a flag)
import { from, pull, bytes, text } from 'node:stream/iter';
import { compressGzip, decompressGzip } from 'node:zlib/iter';

const compressed = await bytes(pull(from('hello'), compressGzip()));
console.log(await text(pull(from(compressed), decompressGzip())));
Run and output
node --experimental-stream-iter iter.mjs
# (node:...) ExperimentalWarning: stream/iter is an experimental feature and might change at any time
# (node:...) ExperimentalWarning: zlib/iter is an experimental feature and might change at any time
# hello

What changed recently

Table 4. zlib changes by version
Version Change

v22.2.0, v20.15.0

zlib.crc32() added.

v22.15.0, v23.8.0

Zstandard (createZstdCompress(), zstdCompress() and friends) added, Experimental.

v24.7.0, v22.20.0

CompressionStream accepts the brotli format.

v25.9.0

node:zlib/iter added (Experimental, flag-gated).

v26.5.0

rejectGarbageAfterEnd option added (backported to v24.19.0; not in Node.js 22).

v26.7.0

The Zstandard dictionary option accepts a TypedArray, DataView or ArrayBuffer.

v26.8.0

The ZIP archive API (ZipFile, ZipBuffer, ZipEntry, createZipArchive(), zipFiles()) added, Early development.

The constants used to be readable directly from the module (zlib.Z_NO_FLUSH); use zlib.constants.Z_NO_FLUSH, because the direct access is deprecated. The Zlib class was renamed ZlibBase in v11.7.0 and v10.16.0.

Where the books and older tutorials are outdated

Efficient Node.js by Samer Buna introduces zlib as one of the built-in stream transforms, and many older tutorials show only gzip with .pipe(). The following points have changed or are missing from them.

Table 5. Older compression advice against current practice
Older advice Current approach

Chaining .pipe() between createReadStream(), createGzip() and the destination

Use pipeline() from node:stream/promises, which forwards errors and cleans up every stage.

gzip is the only built-in format

Brotli has been built in since v11.7.0 and v10.16.0, and Zstandard exists (Experimental) since v22.15.0 and v23.8.0.

Add an npm package for CRC-32

zlib.crc32() since v22.2.0 and v20.15.0.

Add an archiving package to write ZIP files

Node.js 26.8.0 adds an experimental ZIP API; on Node.js 24 and 22 a library is still needed.

Compress every response on the fly with createGzip()

Cache the compressed variants, send Vary: Accept-Encoding, and avoid synchronous methods in request handlers.

No limit on decompressed size

Set maxOutputLength for one-shot calls and count bytes for streams; the ZIP API has setMaxZipContentSize().