Node.js Zstd Compression: The 8 MB Window Rule

Node.js has had zstd compression in node:zlib since v23.8.0 and v22.15.0, through zlib.createZstdCompress() and zlib.zstdCompressSync(). Serving it to browsers is safe if you negotiate on Accept-Encoding, keep gzip as the fallback, and stay at level 19 or below. Levels 20 to 22 can declare a window over 8 MB, and Chrome and Firefox refuse to decode those responses.

Nothing in Node warns you about the last part. The compressor accepts level 22, the response goes out with a 200, and the browser throws it away.

Which Node.js versions have zstd, and is it stable?

The Node.js zlib documentation lists the zstd classes and functions as added in v23.8.0 and v22.15.0, and still marks them Stability: 1 - Experimental in both the v24 and the current v26 docs as of November 2026. Node’s stability index says an Experimental feature is not covered by semantic versioning, can change or be removed in any release, and is “not recommended in production environments”. The surface itself is small: createZstdCompress, createZstdDecompress, the four zstdCompress/zstdDecompress callback and sync functions, and a params object keyed by zlib.constants, the same pattern as Brotli.

On Node 24.12.0 none of these printed an ExperimentalWarning in our runs. process.versions.zstd reports the bundled library, 1.5.7 on that release.

The browser side is settled. Chrome shipped zstd Content-Encoding in 123, Firefox in 126, and MDN’s compatibility data lists Safari from 26.3, with a note that Safari on macOS before 26.3 Tahoe cannot decode Zstandard responses.

Does zstd actually beat gzip and Brotli in Node?

It depends on the level, and at the default level it can lose to gzip. We ran each streaming compressor seven times over two payloads on Node 24.12.0 (zstd 1.5.7, Brotli 1.1.0, zlib 1.3.1) on an Apple M4 Max and took the median. The payloads were React DOM 19.2.0’s react-dom-client.production.js (536,016 bytes) and a 1 MB JSON page from our CMS (1,018,905 bytes).

Encoderreact-dom sizeTimeCMS JSON sizeTime
gzip 695,5875.8 ms346,83915.6 ms
Brotli 499,2582.7 ms343,0087.6 ms
Brotli 590,3434.2 ms321,51513.3 ms
zstd 3 (default)104,7191.1 ms339,4633.3 ms
zstd 691,7182.9 ms317,4447.5 ms
zstd 989,4914.5 ms310,35810.8 ms

Default zstd is by far the fastest, but on the JavaScript bundle it came out 9.6% larger than gzip. Level 6 is where it pulls ahead on both: smaller than gzip in about half the time, and close to Brotli 5 at a lower cost. These are two files on one machine, so treat the ordering as a starting point for measuring your own responses, not a law.

For files you compress once at build time the picture changes. On the 9.1 MB typescript.js from TypeScript 5.9.3, one-shot zstd 19 came to 1,156,748 bytes in about 1.7 seconds, and Brotli 11 to 1,150,586 bytes in about 8 seconds. Brotli was still slightly smaller. Decompression in Node was faster for zstd in every case we measured (4.5 ms against 8.2 ms for that file), which matters to the client, not your build.

Why do zstd levels 20 to 22 break browsers?

Zstandard frames declare a window size: how far back a match can reach, and so how much memory the decoder must hold. RFC 9659 fixes this for HTTP. Encoders using the zstd content coding “MUST NOT generate frames requiring a Window_Size larger than 8 MB”, and decoders must support up to 8 MB. It updates RFC 8878, which had only recommended the limit.

Node’s compressor knows nothing about HTTP. Reading the frame headers it produced for a streamed 200 KB input, the window log grows with the level: 19 at level 1, 20 at level 2, 21 at levels 3 to 8, 22 at 9 to 16, and 23 (exactly 8 MB) at 17 to 19. Then it jumps to 25, 26 and 27 for levels 20, 21 and 22.

We served each variant from a local Node server to Playwright 1.61.1’s Chromium 149 and Firefox 151. Levels 3 and 19 decoded. Levels 20 and 22 failed in both (we did not serve 21), even for a 22 KB body, because a streamed frame declares the level’s window whether or not the content needs it. Playwright reported Chromium’s failed request as net::ERR_ZSTD_WINDOW_SIZE_TOO_BIG and fetch() rejected with TypeError: Failed to fetch. Firefox rejected with TypeError: Decoding failed.

One-shot compression behaves differently. zstdCompressSync knows the input size, so for small inputs it writes a single-segment frame whose window is the content size, and level 22 on a 6 KB body wrote a 6,000-byte window. On the 9.1 MB file, level 20 produced a 9.1 MB window and both browsers rejected it. So a level 20 setting can pass every test with small fixtures and fail on your largest bundle.

The fix is ZSTD_c_windowLog. Setting it to 23 fixed both failing shapes we tried, a streamed level 22 body and the one-shot level 20 file, in both browsers. It also showed the ultra levels are not worth having here: capped at 23, level 22 shrank typescript.js by 192 bytes compared with level 19, and made the React DOM file 12 bytes larger.

// build-zst.mjs: node build-zst.mjs dist/app.js
import { readFile, writeFile } from 'node:fs/promises';
import zlib from 'node:zlib';

const { constants } = zlib;
const file = process.argv[2];
const input = await readFile(file);

const output = zlib.zstdCompressSync(input, {
  params: {
    [constants.ZSTD_c_compressionLevel]: 19,
    // 19 already uses 2^23. Pinning it means a later edit to 20, 21 or 22
    // cannot ship a frame that Chrome and Firefox refuse to decode.
    [constants.ZSTD_c_windowLog]: 23,
  },
});

await writeFile(`${file}.zst`, output);
console.log(`${input.length} -> ${output.length} bytes`);

Do not set windowLog to 23 on the dynamic path. At level 6 it raised the declared window from 2 MB to 8 MB and the output did not shrink by a single byte. It only asks every client for more memory.

How to serve zstd from a Node.js server with a fallback

Negotiate on Accept-Encoding. RFC 9110 defines the header, including q=0 meaning “not acceptable” and * covering anything not listed. A regex test for the substring zstd, which is what the Node docs’ own example does, gets zstd;q=0 wrong.

// encoding.mjs
// Pick a response encoding from an Accept-Encoding header (RFC 9110, 12.5.3).
export function pickEncoding(header = '', preferred = ['zstd', 'br', 'gzip']) {
  const weights = new Map();
  for (const part of header.toLowerCase().split(',')) {
    const [token, ...params] = part.trim().split(';');
    if (!token) continue;
    const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
    weights.set(token, q ? Number(q.slice(2)) : 1);
  }
  let best = null;
  let bestWeight = 0;
  for (const name of preferred) {
    const weight = weights.get(name) ?? weights.get('*') ?? 0;
    if (weight > bestWeight) {
      best = name;
      bestWeight = weight;
    }
  }
  return best; // null means send it uncompressed
}

Ties go to the first entry in preferred, so a browser sending gzip, deflate, br, zstd gets zstd. A request with no header gets an uncompressed body. RFC 9110 says any coding is acceptable in that case, but plain is the choice that cannot go wrong.

The server compresses anything over 1 KB, always sends Vary: Accept-Encoding so caches keep one copy per encoding, and refuses to start above level 19.

// server.mjs (Node 22.15+ or 23.8+)
import http from 'node:http';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import { pickEncoding } from './encoding.mjs';

const { constants } = zlib;
const ZSTD_LEVEL = 6;

// Levels 20 to 22 use a window over 8 MB, which RFC 9659 forbids for HTTP.
if (ZSTD_LEVEL > 19) throw new Error('zstd level above 19 breaks browsers');

const encoders = {
  zstd: () =>
    zlib.createZstdCompress({
      params: { [constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL },
    }),
  br: () =>
    zlib.createBrotliCompress({
      params: { [constants.BROTLI_PARAM_QUALITY]: 5 },
    }),
  gzip: () => zlib.createGzip({ level: 6 }),
};

async function render() {
  const items = Array.from({ length: 2000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
  return JSON.stringify({ items });
}

http
  .createServer(async (req, res) => {
    try {
      const body = await render();
      const encoding =
        body.length >= 1024 ? pickEncoding(req.headers['accept-encoding']) : null;

      res.setHeader('Content-Type', 'application/json');
      res.setHeader('Vary', 'Accept-Encoding');
      if (!encoding) return res.end(body);

      res.setHeader('Content-Encoding', encoding);
      await pipeline([body], encoders[encoding](), res);
    } catch (err) {
      console.error(err);
      if (!res.headersSent) res.writeHead(500);
      res.end();
    }
  })
  .listen(3000);

Chromium and Firefox both received zstd from it and parsed the JSON. A request with Accept-Encoding: gzip, deflate, br got Brotli, gzip got gzip, and identity got the 59,791-byte original. The zstd body was 3,436 bytes.

Skipping negotiation fails quietly. Playwright’s WebKit 26.5 build did not advertise zstd (it sent gzip, deflate over HTTP and gzip, deflate, br over HTTPS), and when we sent zstd anyway, fetch() resolved with a 200 and a body of garbage: 34 characters where 22,007 were expected. That is Playwright’s WebKit, not Safari 26.3, but it shows that a client which cannot decode zstd may not raise an error at all. Shared caches are the other half of this: our guide to stale-while-revalidate and Cache-Control covers what happens when a response varies and Vary does not say so.

What about decoding zstd in Node?

Node’s own fetch (Node 24.12.0) sent Accept-Encoding: gzip, deflate, yet decoded a zstd response when a server sent one anyway, including the 9.1 MB-window frame the browsers rejected. So a Node client is not a test of whether your output will work in a browser.

Accepting zstd uploads needs two limits. ZSTD_d_windowLogMax refuses frames that want too much memory, and maxOutputLength caps the decompressed size. Without the second, 100 MiB of zeros compressed to 3,219 bytes.

// decode-upload.mjs
import zlib from 'node:zlib';

const { constants } = zlib;

export function decodeZstdUpload(buffer) {
  try {
    return zlib.zstdDecompressSync(buffer, {
      params: { [constants.ZSTD_d_windowLogMax]: 23 },
      maxOutputLength: 10 * 1024 * 1024,
    });
  } catch (err) {
    // ZSTD_error_frameParameter_windowTooLarge or ERR_BUFFER_TOO_LARGE
    const error = new Error('Unacceptable zstd body', { cause: err });
    error.status = 413;
    throw error;
  }
}

A level 22 stream failed with ZSTD_error_frameParameter_windowTooLarge (“Frame requires too much memory for decoding”) and the 100 MiB bomb with ERR_BUFFER_TOO_LARGE. Compressing in the browser is a separate question, covered in our CompressionStream write-up; zstd is not in that spec’s list of formats.

Should you switch your Node.js server to zstd?

Start with the experimental label. If your release policy follows Node’s advice on Experimental features, put zstd in your CDN or reverse proxy and leave Node on Brotli and gzip. If you accept that the API may change, use it for dynamic responses at level 6 or so, behind proper negotiation with Brotli and gzip still in the list. That is where zstd’s speed buys something: roughly Brotli 5 sizes for less CPU per request, as measured above. Keep the level check in code, because the failure is silent on the server.

For static assets you already precompress with Brotli 11, adding .zst files gains nothing on size. Brotli 11 beat zstd 19 on all three test files and on every one of the 329 text files over 1 KB in this site’s own build. Do it only if your host serves precompressed zstd and client decode time matters to you.

What we did not test: Node 22 (every sample ran on Node 24.12.0 only), Safari itself, a real CDN, and HTTP/2 or HTTP/3. The browser results come from Playwright’s builds on macOS against a local HTTP/1.1 server.

Response compression is one of the first things we check when a site comes to us for website speed optimisation, alongside caching headers and what the server spends its CPU on per request.

Need this built properly?

Get in touch