A WebSocket connection gives you an ordered, full-duplex pipe of messages and says nothing about what those messages mean. Two optional negotiations in the opening handshake fill that gap. The subprotocol decides the application language spoken on the pipe: which message schema, which encoding, which version. Extensions change the bytes on the wire underneath the application, and in practice there is one that matters: permessage-deflate compression.
Teams get both wrong in predictable ways. They ship a breaking message change with no version negotiation and strand every old mobile client. Or they switch on compression because it is a one-line flag, then watch server memory grow by hundreds of kilobytes per connection. This article explains the negotiation rules from RFC 6455 and RFC 7692 first, then shows how to use them deliberately, with server code, a memory sizing example and the failure modes to plan for. For the frame bits themselves, see WebSocket Frame Format, in depth.
Where the negotiation happens
Both negotiations ride on the HTTP/1.1 Upgrade request that opens the connection. The client lists what it can do; the server answers in its 101 Switching Protocols response with what it chose. Nothing is renegotiated later: whatever the 101 says holds for the lifetime of the connection.
GET /live HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v3.json, chat.v2.json
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat.v2.json
Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeoverThe asymmetry is the core rule. The client may offer many subprotocols and many extension configurations. The server may select at most one subprotocol, and only from the client's list. It may accept any subset of the offered extensions, possibly with tightened parameters, and the order it lists them in is the order they are applied to the data. Silence is a valid answer: no Sec-WebSocket-Protocol header in the response means no subprotocol was selected.
Subprotocols in brief
RFC 6455 makes the client the enforcer. If the server returns a subprotocol or an extension the client did not offer, the client must fail the connection, and browsers do. In JavaScript the offer is the second constructor argument, and both outcomes are readable after open:
const ws = new WebSocket("wss://api.example.com/live", ["chat.v3.json", "chat.v2.json"]);
ws.addEventListener("open", () => {
if (ws.protocol === "") { ws.close(4000, "no common protocol"); return; } // server selected nothing
startCodec(ws.protocol);
console.log(ws.extensions); // e.g. "permessage-deflate; server_no_context_takeover"
});Browser code chooses the subprotocol offer but cannot choose extensions at all: the browser decides what to offer and ws.extensions only reports the outcome. A server that ignores the subprotocol header still completes the handshake, so a client that depends on one must check for the empty string and close. Public names you will meet include graphql-transport-ws (the current GraphQL over WebSocket protocol), graphql-ws (the legacy protocol of the deprecated subscriptions-transport-ws package), mqtt, v12.stomp and the WAMP pair wamp.2.json and wamp.2.msgpack, one protocol in two encodings.
Designing your own subprotocol, versioning it by name, routing on it and migrating clients between versions are covered in WebSocket subprotocols architecture. The rest of this article is about the other negotiation, which changes the bytes rather than their meaning, and which has the larger operational cost.
The extension framework, rule by rule
An extension is allowed to change how frames are built: it may use the three reserved bits RSV1 to RSV3 in the frame header, define new opcodes, or transform payloads. Without negotiation, any of those is a protocol error, so a receiver that sees RSV1 set on a connection with no agreed extension must fail it. That strictness is why extensions cannot be bolted on later and why intermediaries that inspect frames must understand every extension in use.
Negotiation follows a few rules. The client's Sec-WebSocket-Extensions header lists offers, each an extension name with optional parameters, in preference order. The same extension may appear more than once with different parameters, as a list of fallbacks. The server accepts at most one offer per extension, may add or tighten parameters within limits each extension defines, and lists the accepted extensions in the order they apply to outgoing data. The client must fail the connection if the response contains anything it did not offer or parameters it cannot honour.
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits; server_max_window_bits=10,
permessage-deflate; client_max_window_bitsThat offer says: preferably compress with the server limited to a 1 KB window; failing that, compress with any server window. In both, the bare client_max_window_bits tells the server the client can accept a limit on its own window. In practice permessage-deflate, defined in RFC 7692, is the only extension in wide use. Earlier experimental compression extensions were superseded by it, and no general-purpose successor has replaced it.
permessage-deflate, parameter by parameter
RFC 7692 defines permessage-deflate. The sender compresses each message payload with DEFLATE, flushes, strips the trailing four bytes 00 00 ff ff that every sync flush ends with, and sets the RSV1 bit on the first frame of the message. The receiver appends those four bytes and inflates. Control frames (ping, pong, close) are never compressed, and a sender may leave any individual message uncompressed by not setting RSV1, which matters for tiny or already-compressed payloads. Four parameters shape the behaviour; the end-to-end compression pipeline is drawn in WebSocket compression architecture, and this article concentrates on what each parameter costs and how to measure it:
| Parameter | Who it constrains | Effect |
|---|---|---|
server_no_context_takeover | Server compressor | Server resets its LZ77 window after every message |
client_no_context_takeover | Client compressor | Client resets its window after every message |
server_max_window_bits=N | Server compressor | Server window at most 2^N bytes, N from 8 to 15 |
client_max_window_bits[=N] | Client compressor | In an offer with no value: the client can honour a limit. In a response: the limit |
Context takeover is the important choice. With takeover, the compressor keeps its sliding window between messages, so the second {"type":"price","symbol":"ABC",...} message compresses against the first and shrinks dramatically. Small, repetitive JSON is exactly where WebSocket compression earns its keep, and it earns it mostly through takeover. The price is that the window and compressor state must stay allocated for the life of the connection. Without takeover each message is compressed alone, small messages barely shrink, but an implementation can release or pool zlib state between messages.
Window bits trade ratio for memory: 15 is a 32 KB window, 10 is 1 KB. Some implementations decline a value of 8, because zlib's raw DEFLATE mode does not accept an 8-bit window; offer 9 or higher if you need a small window and want broad interoperability.
Worked example: what compression costs per connection
zlib documents its memory use. A deflate stream needs about 2^(windowBits+2) + 2^(memLevel+9) bytes, and an inflate stream about 2^windowBits plus roughly 7 KB. At the defaults, windowBits 15 and memLevel 8, that is 128 KB + 128 KB = 256 KB to compress and about 39 KB to decompress. A server with context takeover in both directions holds both for every connection: roughly 295 KB.
| Configuration (server side) | Per connection | 100,000 connections |
|---|---|---|
| No compression | 0 | 0 |
| Takeover, windowBits 15, memLevel 8 | about 295 KB | about 28 GB |
| Takeover, server windowBits 10, memLevel 4, client windowBits 10 | about 4 + 8 + 1 + 7 = 20 KB | about 2 GB |
| No takeover, state pooled | near 0 while idle | bounded by concurrent senders |
memLevel is not negotiated; it is a local zlib setting, and in the Node ws library you pass it through zlibDeflateOptions. The client window only shrinks if the client offered client_max_window_bits and the server answered with a value. Browsers commonly offer it, but verify with your own clients by logging the request header.
The ws library is a useful reference for defaults: permessage-deflate is disabled by default on its server and enabled by default on its client, messages under a threshold (1024 bytes by default) are not compressed, and a concurrencyLimit (default 10) caps concurrent zlib calls. Its documentation warns that Node's zlib can suffer severe memory fragmentation under concurrency and tells you to load test your workload before enabling compression in production. That is the right posture for any stack. Measure CPU per message and resident memory per connection at your real message mix, with and without compression, and decide from numbers.
Measure before you enable it
Compression ratio depends entirely on your messages, so measure it offline before touching a server. Python's standard zlib module can reproduce what permessage-deflate does to a recorded stream of messages: raw DEFLATE (negative window bits), a sync flush per message, and the four-byte tail stripped.
import json, zlib
def pmd_sizes(messages, wbits=15, mem=8, takeover=True):
comp = zlib.compressobj(6, zlib.DEFLATED, -wbits, mem)
out = []
for m in messages:
if not takeover:
comp = zlib.compressobj(6, zlib.DEFLATED, -wbits, mem) # fresh window per message
data = comp.compress(m) + comp.flush(zlib.Z_SYNC_FLUSH)
assert data.endswith(b"\x00\x00\xff\xff")
out.append(len(data) - 4)
return out
msgs = [l.encode() for l in open("recorded_messages.jsonl", encoding="utf-8")]
raw = sum(map(len, msgs))
for wbits, takeover in [(15, True), (10, True), (15, False)]:
z = sum(pmd_sizes(msgs, wbits=wbits, takeover=takeover))
print(f"wbits={wbits} takeover={takeover}: {z / raw:.0%} of original")Run it on a few thousand real messages. Typical results for small, repetitive JSON show takeover cutting size several-fold while per-message compression barely helps; a 10-bit window often keeps most of the gain. If your payloads are already compressed images or protobufs, all three rows sit near 100 percent and the answer is to leave compression off. When the numbers justify it, configure the server explicitly rather than accepting defaults:
const wss = new WebSocketServer({
port: 8080,
maxPayload: 1 << 20, // limit applies to the inflated message
perMessageDeflate: {
serverMaxWindowBits: 10,
clientMaxWindowBits: 10, // only takes effect if the client offered the parameter
zlibDeflateOptions: { memLevel: 4, level: 3 },
threshold: 1024, // do not compress small messages
concurrencyLimit: 10,
},
});
Compression oracles and other security edges
Compression leaks information through length. If one compression context sees both attacker-influenced text and a secret, the attacker can guess the secret a character at a time: a correct guess repeats existing bytes and compresses smaller. This is the mechanism behind the CRIME and BREACH attacks on TLS and HTTP compression, and context takeover widens it, because the secret from message 1 sits in the window when message 2 is compressed.
- Never send session tokens, CSRF tokens or personal data in the same compressed stream as user-controlled content that an attacker can choose and observe.
- If you must mix them, send the sensitive messages uncompressed (RSV1 clear) or use no context takeover, and remember that within a single message the leak still exists.
- Cap decompressed size. A tiny compressed frame can inflate to gigabytes; enforce a maximum message size on the inflated output, not on the wire bytes. In
wsthat ismaxPayload.
Proxies, load balancers and HTTP/2
A proxy that only tunnels the connection passes both headers through untouched, and the negotiation happens end to end. A proxy that terminates WebSocket, as many API gateways and some CDNs do, creates two connections, and each hop negotiates its own extensions. Compression can be on between client and edge and off between edge and origin. Subprotocols are different: the origin chooses the schema, so the gateway must forward Sec-WebSocket-Protocol to the origin and return the origin's choice. Test this explicitly; a gateway that drops the header produces the empty-protocol case on every connection. WebSocket load balancer pitfalls covers the rest of the path.
Over HTTP/2, RFC 8441 bootstraps WebSocket with an extended CONNECT request carrying :protocol = websocket, after the server advertises SETTINGS_ENABLE_CONNECT_PROTOCOL. The key and accept headers disappear, but Sec-WebSocket-Protocol and Sec-WebSocket-Extensions work exactly as before. Client and server support varies by release, so check your actual stack; HTTP/2 for bidirectional streaming explains the transport.
Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser closes immediately after 101 | Server returned a subprotocol or extension the client did not offer | Echo only offered names; never hard-code a response header |
ws.protocol is always empty | Gateway strips the header, or server never selects | Forward the header; log offered vs selected names |
| Old clients fail on first message | Breaking schema change without a new subprotocol name | Version by name; keep the old codec until the miss counter is near zero |
| Memory grows linearly with connections | Context takeover at 15-bit windows | Shrink windows and memLevel, or disable takeover |
| Peer closes with 1002 protocol error | RSV1 set without negotiated deflate, or bad compressed data | Check both sides agree in the 101; capture frames |
| CPU spikes at fan-out | Compressing the same broadcast once per connection | Disable takeover for broadcast channels or skip compression for them |
What to do next
- Log the offered and selected subprotocol and extensions for every connection for one day.
- Give your message schema an explicit versioned subprotocol name and select by server preference.
- Close connections with no common subprotocol using a 4000-range code and a readable reason.
- Measure resident memory per connection and CPU per message with compression on and off at your real message mix.
- Pick context takeover, window bits and memLevel from that measurement, not from library defaults.
- Audit which streams carry secrets next to user-controlled text, and send those uncompressed.
- Set a decompressed message size limit on every server.
- Verify each proxy hop forwards the subprotocol header, over both HTTP/1.1 and HTTP/2 if you use it.