Skip to content
Node.js nd core 4 min read

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.

AlgorithmFunctionsHeader overheadNotes
gzipgzip / gunzip~18 bytesUniversally understood; the default for Content-Encoding: gzip.
deflatedeflate / inflate~6 bytesSame DEFLATE core as gzip with a smaller zlib wrapper; use deflateRaw for no wrapper.
brotlibrotliCompress / brotliDecompressminimalBest 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 br support.

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 (or node:stream/promises) so errors propagate and resources are released on failure.
  • Avoid *Sync functions in servers — they block the event loop and stall every concurrent request.
  • Negotiate encoding from Accept-Encoding and send Content-Encoding plus Vary: Accept-Encoding to 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_QUALITY to your workload: high quality for static assets built once, lower for dynamic per-request responses.
Last updated June 14, 2026
Was this helpful?