โ† back to journal

In Node, WebSocket compression won't get small

2026-10-14 ยท Kit Wickham

WebSocket compression (permessage-deflate) keeps a deflate compressor alive for every connection, and with zlib's defaults that costs about 212 KB per connection. Two settings shrink it: the window size and zlib's memLevel. With stock zlib, a 4 KB window and memLevel 4 cut that to 28 KB, and on three live message streams the output grew by 4% to 34%. In Node, the same settings only go from 327 KB to 204 KB. Node ships Chromium's fork of zlib, which keeps a 64 KB hash table no matter what memLevel you ask for.

The setup

With context takeover (the normal mode), a permessage-deflate sender runs one deflate stream for the whole connection and flushes it at the end of each message, so each message can point back at earlier ones. RFC 7692 lets the two sides agree on a smaller window, from 28 to 215 bytes, through server_max_window_bits. memLevel isn't part of the protocol; it's a local zlib setting (1 to 9, default 8) that sizes the hash table and the buffer deflate collects a block in.

I recorded three real streams, one connection each: 37,944 messages from Bluesky's Jetstream firehose (average 634 bytes), 7,513 ticker and trade messages from Coinbase's public market feed for five coins (344 bytes), and 5,706 edit events from Wikimedia's recentchange stream (1,340 bytes). Each was replayed through a compressor the way a server sends: compress the message, sync flush, drop the four trailing bytes the RFC says to drop. Ratios below are output bytes over input bytes at level 6, from Python 3.14 with stock zlib 1.3.1. Node's zlib gave ratios within 1.5% of these on the Jetstream messages I checked.

What each setting buys

Window, memLevelJetstreamCoinbaseWikimediaStock zlibNode
32 KB, 8 (default)0.3130.1940.165212 KB327 KB
32 KB, 40.3140.1940.166140 KB313 KB
8 KB, 40.3210.2020.20144 KB223 KB
4 KB, 40.3260.2090.22128 KB204 KB
2 KB, 40.3380.2220.27020 KB201 KB
1 KB, 40.3670.2620.45516 KB198 KB
no context takeover0.5830.6520.476

The memory columns are resident memory per connection: 1,000 compressors in one process, each fed the same 300 Jetstream messages (about 200 KB, so the whole window gets used), median of three runs on Linux. The Node column includes the stream object itself; a plain pass-through stream doing the same work cost 43 KB.

Two things stand out. memLevel above 4 bought nothing: on all three streams memLevel 4 matched memLevel 8 to within 0.001 at every window from 1 KB up, while saving 72 KB per connection in stock zlib. Because every message ends with a flush, deflate never fills the big block buffer memLevel 8 pays for. The window is where the trade-off lives, and where it starts to hurt follows message size. Coinbase and Jetstream messages are small, so a 4 KB window still holds six to twelve of them, and the output grows by 8% and 4% compared to the defaults. Wikimedia's messages are two to four times bigger and keep finding matches far back: at 4 KB the output is 34% bigger, at 1 KB it nearly triples, and a 512-byte window does worse than no context takeover at all.

Why Node stays big

In stock zlib, memory follows the documented formula: 2window bits + 2 plus 2memLevel + 9 bytes, plus a few KB of state. Node uses Chromium's zlib, and its deflate.c raises the hash table to 215 entries whenever memLevel asks for fewer (unless it's built with its older rolling hash). That's 64 KB per connection you can't turn down. memLevel still sets the block buffer, so it saves a little, but going from 32 KB to 4 KB windows saves 109 KB in Node against 112 KB in stock zlib, and in Node the rest of the floor stays.

The other knob that mattered in Node is the stream's output chunk size, which defaults to 16 KB. The popular ws library passes its zlibDeflateOptions straight to Node's zlib, and its README example sets chunkSize: 1024, memLevel: 7 and level 3. With the default 32 KB window, that measured 275 KB per connection. A 4 KB window, memLevel 4 and 1 KB chunks measured 149 KB, and even a 512-byte window with memLevel 1 only reached 145 KB. That's the floor: about 43 KB of stream and about 100 KB of zlib that no setting removes.

What to do

Set memLevel to 4 wherever you can; it costs nothing measurable on flushed message streams. Pick the window from your message size, not from a general rule: keep it at least a few messages long, and measure on recorded traffic if your messages are over a kilobyte, because that's where small windows got expensive. Python's websockets library already defaults to a 4 KB window and memLevel 5 and documents the same trade-off; these numbers say it's a good default for small messages and a costly one for kilobyte-sized events. In Node, plan on roughly 150 to 200 KB per compressed connection whatever you set, so with many mostly idle connections, the choice that saves memory is whether to compress at all. ws already leaves compression off on servers by default.

Method: streams recorded on 14 October 2026 over 2.5, 15 and 10 minutes. Ratios from Python 3.14.8 (zlib 1.3.1), level 6; at level 9 memLevel 4 and 8 again matched within 0.4%, at level 1 within 3%. Node 26.10.0 with its bundled Chromium zlib; the Node ratio check used the first 5,000 Jetstream messages. Memory is the change in resident set size divided by 1,000 connections, one process per setting, three runs each; the three Node runs of a setting usually landed within 5 KB of each other, the worst within 60 KB. Server-to-client compression only: the receiving side's inflate memory isn't measured here. These are three streams of JSON; other traffic will land elsewhere, but the memLevel result held on all three.