Compression with the zlib Module
Compression shrinks payloads before they travel over a network or land on disk, cutting bandwidth, storage, and latency costs. Node.js ships this capability in the built-in node:zlib module, which wraps the battle-tested zlib C library and Google’s Brotli implementation. It exposes three families of algorithms — gzip, raw deflate, and brotli — each available as one-shot callback functions, synchronous variants, and composable streams. This page shows when to reach for each and how to wire them into files and HTTP responses.
Choosing an algorithm
All three algorithms are lossless and interoperate with their counterparts elsewhere (browsers, CDNs, curl). The practical differences come down to ratio, speed, and ecosystem support.
| Algorithm | Functions | Header overhead | Notes |
|---|---|---|---|
| gzip | gzip / gunzip | ~18 bytes | Universally understood; the default for Content-Encoding: gzip. |
| deflate | deflate / inflate | ~6 bytes | Same DEFLATE core as gzip with a smaller zlib wrapper; use deflateRaw for no wrapper. |
| brotli | brotliCompress / brotliDecompress | minimal | Best ratio for text; slightly slower to compress at high quality. |
Brotli typically produces 15-25% smaller output than gzip for HTML, CSS, and JSON, which is why it is the preferred encoding for static web assets when the client advertises
brsupport.
One-shot compression with callbacks
For data already buffered in memory, the asynchronous one-shot helpers run the work off the main thread on the libuv thread pool and hand you the result via a callback. Every compressor has a matching decompressor.
import { gzip, gunzip } from 'node:zlib';
import { Buffer } from 'node:buffer';
const payload = Buffer.from('Devcraftly '.repeat(20));
gzip(payload, (err, compressed) => {
if (err) throw err;
console.log(`original: ${payload.length} bytes`);
console.log(`compressed: ${compressed.length} bytes`);
gunzip(compressed, (err, restored) => {
if (err) throw err;
console.log(`restored: ${restored.length} bytes`);
console.log(`round-trip ok: ${restored.equals(payload)}`);
});
});
Output:
original: 220 bytes
compressed: 31 bytes
restored: 220 bytes
round-trip ok: true
To avoid nested callbacks, promisify the functions — or import the promise-based wrappers and use await:
import { promisify } from 'node:util';
import zlib from 'node:zlib';
const brotli = promisify(zlib.brotliCompress);
const unbrotli = promisify(zlib.brotliDecompress);
const text = JSON.stringify({ service: 'api', items: Array(50).fill('node') });
const packed = await brotli(text);
const unpacked = await unbrotli(packed);
console.log(`${Buffer.byteLength(text)} -> ${packed.length} bytes`);
console.log(`match: ${unpacked.toString() === text}`);
Output:
331 -> 47 bytes
match: true
In CommonJS the imports become const { gzip, gunzip } = require('node:zlib'); — the API is identical.
Synchronous variants
Each function has a *Sync form that returns a Buffer directly. These block the event loop, so reserve them for CLI scripts, startup-time work, or small payloads — never inside a request handler under load.
import { deflateSync, inflateSync } from 'node:zlib';
const compressed = deflateSync('compress me synchronously');
const original = inflateSync(compressed).toString();
console.log(original);
Output:
compress me synchronously
Tuning with options
Pass an options object as the second argument to trade speed for ratio. zlib uses level (0-9); brotli uses a params map keyed by named constants.
import zlib from 'node:zlib';
const data = Buffer.from('x'.repeat(10_000));
const fast = zlib.gzipSync(data, { level: zlib.constants.Z_BEST_SPEED });
const small = zlib.gzipSync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
const brotliMax = zlib.brotliCompressSync(data, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length,
},
});
console.log({ fast: fast.length, small: small.length, brotli: brotliMax.length });
Output:
{ fast: 49, small: 29, brotli: 17 }
Streaming and piping
The real power of zlib is its stream interface. Every algorithm has a transform-stream constructor (createGzip, createGunzip, createBrotliCompress, and so on) that you drop into a pipeline. This compresses data incrementally without ever holding the whole input in memory — essential for large files.
Use stream.pipeline rather than chaining .pipe() calls, because it propagates errors and cleans up every stream on failure.
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('access.log'),
createGzip({ level: 9 }),
createWriteStream('access.log.gz'),
);
console.log('compressed access.log -> access.log.gz');
Output:
compressed access.log -> access.log.gz
Decompressing is the mirror image — swap in createGunzip() and reverse the source and destination paths.
Compressing HTTP responses
When serving content, inspect the client’s Accept-Encoding header, pick the best algorithm both sides support, and pipe the body through the matching stream while setting Content-Encoding.
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { createGzip, createBrotliCompress } from 'node:zlib';
import { pipeline } from 'node:stream/promises';
createServer(async (req, res) => {
const accept = req.headers['accept-encoding'] ?? '';
const source = createReadStream('index.html');
let encoder;
if (accept.includes('br')) {
encoder = createBrotliCompress();
res.setHeader('Content-Encoding', 'br');
} else if (accept.includes('gzip')) {
encoder = createGzip();
res.setHeader('Content-Encoding', 'gzip');
}
res.setHeader('Content-Type', 'text/html');
res.setHeader('Vary', 'Accept-Encoding');
try {
if (encoder) {
await pipeline(source, encoder, res);
} else {
await pipeline(source, res);
}
} catch {
res.destroy();
}
}).listen(3000);
Setting Vary: Accept-Encoding tells caches to store a separate entry per encoding so a gzip client never receives a brotli body.
Best Practices
- Prefer the streaming constructors for files and HTTP bodies of unknown or large size; keep one-shot functions for small in-memory buffers.
- Always use
stream.pipeline(ornode:stream/promises) so errors propagate and resources are released on failure. - Avoid
*Syncfunctions in servers — they block the event loop and stall every concurrent request. - Negotiate encoding from
Accept-Encodingand sendContent-EncodingplusVary: Accept-Encodingto stay cache-correct. - Skip compressing already-compressed payloads (JPEG, PNG, MP4,
.zip) — you spend CPU for no size win. - Reach for brotli on text assets when the client supports
br; fall back to gzip for universal compatibility. - Tune
level/BROTLI_PARAM_QUALITYto your workload: high quality for static assets built once, lower for dynamic per-request responses.