permessage-deflate Compression Trade-offs #

Someone enables perMessageDeflate: true because bandwidth looks high, and a week later the pods that comfortably held 20,000 connections are OOM-killed at 12,000. Nothing else changed. The bandwidth graph did improve — by about 8%, on payloads that were already small — and the memory graph went up by 6 GB, because zlib allocates state per socket and nobody costed it.

Compression on a WebSocket is a genuinely useful tool with a genuinely large per-connection price. This page is the arithmetic.

Root cause #

permessage-deflate is an RFC 7692 extension negotiated at the handshake. It applies DEFLATE to message payloads, and its effectiveness comes mostly from context takeover: the compressor keeps its sliding window between messages, so the second time it sees a repeated key name it emits a back-reference instead of the bytes.

That window is the cost. A zlib deflate context with the default window size holds roughly 256 KB, and an inflate context another 32 KB or so — per direction, per socket. At 20,000 connections with both directions active that is on the order of 5–6 GB of memory doing nothing but remembering what previous messages looked like.

Three further properties surprise people:

Small messages barely compress. DEFLATE needs repetition within its window to find matches. A 120-byte JSON message compresses to perhaps 100 bytes on its own — and with the per-message extension bytes and the CPU round trip, that is not a saving worth having.

Compression happens on the event loop. ws can offload deflate to a thread pool, but the default path compresses inline, so a broadcast to 5,000 sockets performs 5,000 compressions before the loop is free again. Under load this shows up as event-loop lag rather than as CPU.

It changes what bufferedAmount means. Bytes waiting to be compressed count towards the queue before compression shrinks them, so a backpressure threshold tuned pre-compression fires differently afterwards.

What one compressed connection actually costs Each compressed WebSocket connection holds a deflate context of roughly 256 kilobytes and an inflate context of around 32 kilobytes, pays CPU per message, and saves bytes only where the payload contains repetition. What one compressed connection actually costs Deflate context (outbound) sliding window kept between messages ~256 KB Inflate context (inbound) smaller, still per socket ~32 KB Per-message CPU compress on send, decompress on receive µs-ms Bytes saved only where payloads repeat within the window 0-70% The memory is charged per connection whether or not any given message compresses well
The top two bands are paid on every connection; the bottom one is earned only by repetitive payloads.

Resolution #

Configure it deliberately rather than switching it on. The settings that matter most are the memory level, the threshold below which messages skip compression entirely, and whether context takeover is enabled at all.

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
// Compression level: 1-3 captures most of the benefit for JSON-ish payloads.
// Level 9 costs several times the CPU for a few extra percent.
zlibDeflateOptions: { level: 3, memLevel: 7, windowBits: 13 },
zlibInflateOptions: { windowBits: 13 },

// Messages below this size skip compression entirely — the single most
// valuable setting here, because most real-time messages are small.
threshold: 2048,

// Disabling context takeover drops the per-socket window at the cost of a
// much worse ratio. Use it when memory, not bandwidth, is the constraint —
// and it is also the mitigation when secrets share a stream with user input.
serverNoContextTakeover: false,
clientNoContextTakeover: false,

// Concurrency cap for zlib work so compression cannot starve the event loop.
concurrencyLimit: 10,
},
});

windowBits: 13 is the lever most teams miss. It reduces the sliding window from 32 KB to 8 KB, which cuts the per-socket deflate context to roughly a quarter while costing surprisingly little ratio on payloads whose repetition is local — which describes most JSON. Combined with memLevel: 7, a compressed connection drops from around 256 KB to well under 100 KB.

The threshold is the other high-value setting. Set it above your median message size and compression only engages for the payloads that actually benefit: document snapshots, batched updates, long text. Everything small passes through uncompressed, at no CPU cost, with no ratio to speak of lost.

Heap needed per node, by connection count Estimated heap for 5,000 to 100,000 concurrent sockets at 132 kilobytes per socket plus 8 kilobytes of application state, against a 1024 megabyte pod limit. Heap needed per node, by connection count 132 KB socket + 8 KB app state each — dashed line is a 1024 MB pod limit Socket + buffers App state 645 MB 5.0k 684 MB 1.3k MB 10k 1.4k MB 2.6k MB 20k 2.7k MB 5.2k MB 40k 5.5k MB 1024 MB pod limit
Heap per node with compression enabled at tuned settings (34 KB socket + ~98 KB zlib context). The same pod that held 40,000 plain connections holds far fewer once every socket carries a compression window.

Verification #

Measure the ratio you actually get and the memory you actually pay, in that order.

# Was the extension negotiated at all? (Sec-WebSocket-Extensions in the 101)
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Extensions: permessage-deflate" http://localhost:8080/ws | grep -i extensions

# Per-connection memory delta: same load, extension on and off
node --max-old-space-size=2048 server.js & LOADGEN=5000 npm run loadtest
curl -s localhost:9090/metrics | grep -E 'nodejs_heap_size_used_bytes|ws_connections_active'

In DevTools, the Messages tab shows the uncompressed payload but the Network panel’s size column reflects what crossed the wire, so the ratio is the difference between them. Do that comparison on real production payloads rather than a synthetic string — repeated "aaaa" compresses beautifully and tells you nothing.

The decision rule that falls out: if your measured ratio is below about 30% on real traffic, the memory is not worth it. If it is above 60% and your payloads are large, it almost certainly is.

Enable or not, by traffic shape A table matching traffic shapes to their typical compression ratio and whether permessage-deflate is worth enabling for each. Enable or not, by traffic shape Typical size Ratio Verdict Cursor / presence pings 40-120 B ~0% no Chat messages 100-400 B 10-25% no Batched state updates 2-20 KB 60-80% yes Document snapshots 50 KB+ 70-90% yes Already-binary payloads any ~0% no Mixed, secret + user input any n/a no takeover A threshold setting lets one connection get both answers: small messages skip compression, large ones take it
Most real-time traffic is in the top two rows, which is why 'on by default' is usually the wrong default.

Operational checklist #

FAQ #

Does compression help or hurt latency? #

Both, in different places. It reduces transmission time, which dominates on slow links — so a mobile client on a weak connection genuinely receives large messages sooner. It adds compression time on the server and decompression on the client, which dominates when the payload is small and the link is fast. For a 20 KB snapshot over a 3G connection the net is strongly positive; for a 60-byte cursor update over fibre it is negative.

What is context takeover, and should I disable it? #

With context takeover the compressor keeps its sliding window between messages, so repeated content across messages compresses well — which is most of the benefit for JSON traffic. Disabling it resets the window per message, dropping per-socket memory to almost nothing and the ratio to roughly what each message can achieve alone. Disable it when memory is your binding constraint, or when you have the CRIME-style concern below.

Is permessage-deflate a security risk? #

It can be, in the specific case where attacker-influenced data and a secret share a compression context. Because compression size depends on content, an attacker who can inject text and observe compressed sizes can infer the secret a byte at a time — the CRIME and BREACH family of attacks. On a WebSocket this requires an unusual combination, but if your stream carries both a session token and arbitrary user input, disable context takeover or do not compress.

Why did my memory grow more than expected? #

Because the extension is negotiated per connection and the context is allocated whether or not any message ever compresses. If you enabled it with a high threshold, you are paying full memory for connections that never compress anything — the worst of both. Either lower the threshold so the memory earns its keep, or turn the extension off for that route entirely.

Should I use compression or binary framing? #

Binary framing first, because it costs no per-socket memory and helps every message, including small ones — see binary WebSocket frames with MessagePack. Add compression on top only for genuinely large payloads, and expect the incremental gain to be smaller than it was on JSON, since a compact binary encoding has already removed much of the redundancy DEFLATE was going to find.

Back to WebSocket Message Framing & Serialization