Why architecture matters here
Architecture matters here because the decision is not 'compress or not' but 'how much per-connection state can I afford, and for which connections'. Those are different questions with different answers per workload, and the extension's design reflects that: it has parameters precisely because there is no globally correct setting. A server holding two hundred long-lived dashboard connections should absolutely take the memory and keep full context. A server holding two hundred thousand mobile connections that each send a heartbeat every thirty seconds should probably not compress at all, because the window costs more than the bytes save.
The second reason is that the costs are invisible at the layer where the decision gets made. A developer enables compression in a config file and observes bandwidth drop. The memory cost appears weeks later at a connection count that did not exist during testing, and it appears as OOM kills rather than as a compression metric. There is no counter labelled 'bytes held in deflate windows'. Making that cost visible — connections times window size times two directions — is an architectural act, because nothing in the stack will do it for you.
Third, compression interacts badly with the other things a WebSocket server does. It happens on the event loop unless you move it, so a large message compresses while nothing else progresses, which turns a bandwidth optimisation into a latency regression measured in the tail. It defeats zero-copy paths, because you cannot splice a buffer you must transform. And it makes message size unpredictable at the frame layer, which complicates every backpressure calculation that was counting bytes.
Finally, the security property is architectural rather than incidental. Whether a compression oracle exists depends on whether secrets and attacker-controlled data can appear in the same message — a question about your message schema, not your transport config. Answering it requires knowing what flows over the socket, which means the decision belongs with the people who designed the protocol, not with whoever tuned the server. A default that compresses everything makes that decision silently, for every message, in the direction of 'yes'.
The architecture: every piece explained
The negotiation is an offer-and-response, and its asymmetry matters. The client sends Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits — potentially several offers, in preference order. The server picks at most one and echoes it back with the parameters it accepts, or omits the header entirely to decline. The server's response is final: there is no counter-offer, and a client that dislikes the answer can only fail the connection. This is why servers must be conservative — an accepted parameter is an allocation the server has just committed to for the connection's lifetime, at the client's suggestion.
The window bits parameters — server_max_window_bits and client_max_window_bits — set the LZ77 history size as a power of two, from 8 (256 bytes) to 15 (32KB), defaulting to 15. This is the ratio-versus-memory dial, and it is far more useful than it looks: dropping to 12 bits gives a 4KB window, cutting memory by eight while typically costing only a few percentage points of ratio on small JSON messages, because those messages rarely reference back more than a few kilobytes anyway. A 32KB window is only earning its keep if your messages are large or highly repetitive across a long span.
Context takeover is the central decision. By default, the compressor keeps its window across messages: message two can reference bytes from message one, which is why the second identical JSON message compresses to almost nothing. Setting server_no_context_takeover or client_no_context_takeover resets the window after each message, so each message compresses standalone. The trade is stark and quantifiable: with takeover, a repetitive chat stream might hit 90% reduction but every connection holds its window forever; without, you might get 40% on the same traffic but the compressor state can be pooled and reused across connections, because nothing needs to persist.
The frame layer carries the result in one bit. RSV1 on the first frame of a message means 'this payload is deflated'; the receiver inflates. Crucially, this is per-message, not per-connection — a server may send some messages compressed and others not, and a good implementation skips compression for payloads under a threshold (a few hundred bytes) and for already-compressed content like images, where DEFLATE reliably makes things larger while spending CPU to do it. The trailing four bytes of the deflate block are stripped and re-added by the extension, a detail that matters only when you are debugging a framing bug at three in the morning.
The bounds are what keep the whole thing safe. A decompression bomb — a small frame that inflates to gigabytes — is trivial to construct with DEFLATE, whose maximum ratio is roughly 1000:1. The defence is to bound the inflated size during inflation, aborting the connection when the running total exceeds a limit, rather than bounding the frame size, which measures the compressed bytes and therefore measures nothing. This is the check that most naive integrations omit, because the obvious limit — max frame size — is right there in the config and appears to do the job.
End-to-end flow
Follow a connection. A trading dashboard opens a WebSocket. Its client library offers permessage-deflate; client_max_window_bits, meaning 'I support compression, and I can accept a server-chosen limit on my own window'. The server, which has been sized deliberately, responds with permessage-deflate; client_max_window_bits=12; server_max_window_bits=12. Both sides now allocate 4KB windows rather than 32KB, and context takeover stays on because this connection will live for hours and stream highly repetitive price updates.
The first message goes out: a full snapshot, 40KB of JSON. It compresses to about 6KB — decent, not spectacular, because the window starts empty and there is no history to reference. The frame carries RSV1 set. The client inflates it, and both sides' windows now hold the tail of that snapshot.
The second message is a price tick: 200 bytes of JSON with the same keys, the same instrument name, the same structure. Because context takeover is on, the compressor finds all of it in the window and emits mostly back-references. Two hundred bytes becomes about thirty. This is the whole value proposition, and it only exists because the window survived — with no_context_takeover, that same tick would compress to perhaps 150 bytes, since DEFLATE would be starting cold and paying for its own Huffman table.
Ten thousand ticks later the ratio holds, and the memory has not moved: the window is a fixed 4KB per direction, overwritten in place. This is the property that makes the cost predictable — it is per connection, not per message, and it is paid at handshake. The server's accounting is arithmetic it can do in advance: 10,000 connections times 4KB times two directions is 80MB of windows, plus zlib's per-stream overhead of several KB each, which is the term people forget and which is often larger than the window itself at small window sizes.
Now a different connection: a mobile client on the same server, sending a 20-byte heartbeat every thirty seconds and receiving nothing else. It negotiated the same parameters, so it holds the same windows — roughly 8KB of state plus zlib overhead, to compress 20 bytes twice a minute into 20-ish bytes, because a payload that small has no redundancy to exploit and DEFLATE's block overhead eats the gain. Multiply by two hundred thousand such clients and the extension is pure cost: gigabytes of RSS, measurable CPU, and negative bandwidth savings.
This is why the negotiation is per-connection and why the server must decide rather than accept. The same server, the same code path, two connections with opposite economics — and the only place with enough information to tell them apart is the server at handshake time, where the request path, the user agent, the subprotocol, or the authenticated tenant reveals which kind of client this is. A server that unconditionally accepts the client's offer has delegated a memory-allocation decision to the party that does not pay for it. The correct architecture is a policy function at handshake: compress with takeover for known-repetitive long-lived streams, decline entirely for chatty low-volume clients, and never let the default make the choice, because the default was written by someone who had never seen your traffic.