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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
gzip, deflate, raw deflate: |
2 Stable ( |
Present since the early 0.x releases; |
Brotli: |
2 Stable ( |
Added in v11.7.0 and v10.16.0. |
Zstandard: |
1 Experimental |
Added in v23.8.0 and v22.15.0, so it exists on all three lines but is Experimental on all three. |
|
Stable ( |
Added in v22.2.0 and v20.15.0. |
|
Stable ( |
Added in v26.5.0 and v24.19.0 (the History of the |
ZIP archive API: |
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: |
|
1 Experimental |
Added in v25.9.0; available only with |
|
Stable globals |
The |
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.
| Format | Classes and methods | Typical use |
|---|---|---|
gzip |
|
Files ending in |
deflate ("zlib" format) |
|
The zlib wrapper around DEFLATE with an Adler-32 check. HTTP |
raw deflate |
|
No header and no checksum; for containers that supply their own framing, such as ZIP entries. |
|
|
Decompression only: detects gzip or deflate from the header. It does not read ZIP archives despite the name. |
Brotli |
|
Best ratio for text (HTTP |
Zstandard |
|
Fast with a good ratio; HTTP |
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.
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());
{
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).
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 });
{ 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.
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); }
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.
| Option | What it does |
|---|---|
|
Compression effort (zlib: 0 to 9; Brotli: |
|
Memory against ratio. The documented formula for deflate memory is |
|
Size of the output slab, 16 KiB by default. |
|
The flush mode between writes and for the last chunk; used below to read truncated data. |
|
A ceiling on the output of the convenience methods (not of streams); the default is |
|
A preset dictionary for deflate and inflate (and, since v26.7.0, a |
|
Return |
|
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.
maxOutputLengthimport { 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); }
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
maxOutputLengthto 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.
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); }
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
Source: Node.js API: zlib, truncated data and finishFlush, Node.js API: zlib Options (rejectGarbageAfterEnd)
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.
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));
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).
import { crc32 } from 'node:zlib';
let crc = crc32('hello');
console.log(crc, crc32('world', crc), crc32('helloworld'));
console.log(crc32(Buffer.from('hello', 'utf16le')));
907060870 4192936109 4192936109
1427272415
Source: Node.js API: zlib.crc32()
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 withZipEntry.create()(in memory),ZipEntry.createStream()(from a stream) orZipEntry.createSymlink(), and read withcontent()or the bounded-memorycontentIterator(). Entries are stored with the method'deflate'(the default),'store'or'zstd'and carry a CRC-32 thatcontent()verifies. -
ZipBufferandZipFile: 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 canadd()anddelete()in place, but those edits are not crash-atomic, so write to a copy when durability matters. -
Writers:
createZipArchive(entries)serialises entries to aReadable, andzipFiles([[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.
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());
ExperimentalWarning on standard error)reviews/review.txt 1200 25 8 3854544580
Great book. Great book
268435456
Hello, world!
Source: Node.js API: zlib.ZipFile, Node.js API: zlib.zipFiles(), Node.js API: zlib.setMaxZipContentSize()
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).
Vary, and a client that checks the resultimport { 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(); });
});
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".
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())); });
});
written (probably still buffered inside zlib)
received "tick 1\n"
received "tick 2\n"
Source: Node.js API: zlib flushing
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 allSyncmethods) 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.
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);
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.
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`);
UV_THREADPOOL_SIZE=1 node pool.js
UV_THREADPOOL_SIZE=4 node pool.js
UV_THREADPOOL_SIZE=8 node pool.js
Source: Node.js API: UV_THREADPOOL_SIZE
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.
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);
gzip bytes: 66
2600
Source: Node.js API: CompressionStream
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.
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())));
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
| Version | Change |
|---|---|
v22.2.0, v20.15.0 |
|
v22.15.0, v23.8.0 |
Zstandard ( |
v24.7.0, v22.20.0 |
|
v25.9.0 |
|
v26.5.0 |
|
v26.7.0 |
The Zstandard |
v26.8.0 |
The ZIP archive API ( |
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.
| Older advice | Current approach |
|---|---|
Chaining |
Use |
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 |
|
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 |
Cache the compressed variants, send |
No limit on decompressed size |
Set |