Transport bake-off · SSE + CQRS vs WebSockets
Testing whether HTML over WebSockets actually earns its complexity, or whether reads down an SSE stream and writes up as ordinary HTTP POSTs is both simpler and just as fast. Same domain code, same bytes of HTML, one variable: the wire.
The theory holds. Steady-state latency is the network, not the transport — the wire contributes about 2ms and then stops mattering. Per-connection memory under TLS is a wash: 245 KB for SSE/h2 against 248 KB for WebSocket.
The claim that a persistent connection “avoids repeating the TCP handshake and the HTTP headers on every interaction” is an HTTP/1.1 claim. On HTTP/2 it opens zero connections and costs 33 bytes of headers. The article generalizes a property of the older protocol.
SSE's real advantage isn't parity, it's compression. For HTML — which repeats — SSE reaches ratios WebSocket cannot, at any effort, because of a limit written into the DEFLATE format in 1996.
Chat is not the dividing line. Payload shape is.
100 clients × 30 rounds, TLS throughout. Measured client-side: from dispatch() to the patch carrying that command's nonce arriving on the client's own stream.
Each group below sits on a dashed line marking the network round trip itself — the floor no architecture can beat. Every bar is essentially that floor plus a couple of milliseconds.
The interesting column is the first command after connect — the moment an HTTP/1.1 client has to open a fresh socket, and exactly the cost the article is pointing at.
| Transport | 1st cmd @ 0ms | 1st cmd @ 100ms | Steady @ 100ms | New TCP conns |
|---|---|---|---|---|
| WebSocket | 2.49 ms | 107.8 ms | 103.49 ms | 0 |
| SSE + POST · HTTP/2 | 5.04 ms | 116.29 ms | 105.42 ms | 0 |
| SSE + POST · HTTP/1.1 | 44.37 ms | 259.1 ms | 102.51 ms | 100 |
Connection setup, not interaction. At 100ms RTT an h2 session takes 314ms to establish against WebSocket's 208ms — one extra round trip, because h2 exchanges SETTINGS before the first request goes out where a WebSocket upgrade rides it. You pay that once per page load, not once per click.
Counted below TLS at the proxy, per command round trip, after connect — so this is marginal steady-state cost, not first paint.
For a single 500-byte row, WebSocket wins by 92 bytes. Nobody will notice. But send a whole-board morph — the pattern that makes HTML-over-the-wire pleasant to write — and the result reverses hard.
gzip and br rows are identical because they are the same codec. Asking a WebSocket for brotli gets you deflate — quietly.Three structural reasons, none of them tunable on the WebSocket side.
permessage-deflate (RFC 7692) is the only compression extension the protocol defines, and it negotiates exactly one codec: DEFLATE. An SSE stream is an ordinary HTTP response, so Content-Encoding: br simply applies — and every browser has decoded it in the network stack since 2016.
This is the mechanism. For patch N to compress against patch N−1, the previous copy must still be inside the sliding window. Sweep the window against an 86.3 KB board and the failure is a cliff, not a slope:
A live version of this — two chat panes on one server, one over each transport — sharpens the number. To compress copy N against copy N−1, the window has to hold both. So DEFLATE's usable payload ceiling is about 16 KB, not 32 KB. Growing a chat log and watching cost per patch shows exactly where it breaks:
Keeping the zlib dictionary alive between messages is what gets WebSocket to even 22×. Without it the narrow-patch case collapses from 10.0× to 1.8×. It costs roughly 256 KB per connection, so servers and proxies routinely negotiate no_context_takeover to bound memory — silently halving your ratio.
SSE + brotli is decoded by the browser's network stack: zero JavaScript, off the main thread, before your code sees a byte. To get brotli over a WebSocket you ship a brotli decoder to the client — WASM, 200 KB and up — and run it per message on the main thread. You would be reimplementing a codec every browser already ships, to reach a ratio SSE gets from one response header.
| Codec | Available to | Avg / patch | Warm patch | Ratio | Encode CPU |
|---|---|---|---|---|---|
| none | both | 88,460 B | 88,559 B | 1.0× | 0 ms |
| deflate · ctx takeover | WS default | 3,941 B | 4,256 B | 22.4× | 27.3 ms |
| deflate · no ctx takeover | WS via proxy | 4,496 B | 4,780 B | 19.7× | 18.4 ms |
| gzip stream | SSE | 3,941 B | 4,256 B | 22.4× | 23.3 ms |
| brotli q4 · w22 | SSE only | 82 B | 52 B | 1,075× | 8.2 ms |
| brotli q5 · w24 | SSE only | 68 B | 49 B | 1,301× | 9.8 ms |
Brotli is 48× smaller and 3× cheaper to encode than the best a WebSocket can do — cheaper because once the dictionary is warm it emits back-references instead of doing fresh Huffman work.
A WebSocket is one ordered byte stream, so commands apply in send order for free. Splitting reads from writes gives that up — the question is how much it actually costs.
| Transport | Out-of-order pairs | Delivered | Ack → patch p50 |
|---|---|---|---|
| WebSocket | 0% | 400 / 400 | no ack exists |
| SSE + POST · HTTP/2 | 0% | 400 / 400 | 0.14 ms |
| SSE + POST · HTTP/1.1 | 2.11% | 400 / 400 | −0.12 ms |
Bursts of 10 un-awaited commands. HTTP/2 preserved order in every trial — concurrent POSTs multiplex onto one ordered TCP connection, so they reach the server in send order. HTTP/1.1's 5-socket pool reordered 2.11% of pairs, which is a genuine hazard if your commands don't commute.
The ack→patch race is real but sub-millisecond: the POST's 204 lands ~0.14ms before the patch it caused, so code that re-enables a button on POST success runs just before the DOM updates. Drive UI off the patch, not the ack.
The WebSocket row has no ack because a WebSocket send has no ack at all. socket.send() returns instantly and tells you nothing — no status code, no error, no correlation to a response. The POST gives you one for free. That's a point for the CQRS split, not against it.
The argument that needs no benchmark. Both architectures need the same subscriber registry — the claim that WebSockets need one and SSE doesn't is simply false, and in this codebase that registry is shared verbatim.
What differs is what the write path has to touch:
// SSE + CQRS — the command handler has no transport in it at all. function handleCommand(room, id, nonce) { const row = apply(board, { id, nonce }) return broadcast(room, () => sseEvent(renderPatch(board, row, MODE))) }
handleCommand is a pure function of (room, id, nonce). It's callable from an HTTP route, a cron job, a webhook, a queue consumer, or another user's request, and it never sees a socket. The WebSocket equivalent is a closure inside wss.on('connection'), structurally scoped to one socket — so dispatching from anywhere else means reaching back through the registry anyway. At which point you've re-derived the CQRS split with extra steps.
| Concern | SSE + POST | WebSocket |
|---|---|---|
| Auth | existing cookie/session middleware | re-implement in the message switch |
| Routing | URL + method | hand-rolled switch (msg.t) |
| Validation, CSRF, rate limiting | existing middleware stack | hand-rolled |
| Observability | access logs, APM spans, per-route metrics | one opaque long-lived connection |
| Errors | status codes | invent a convention |
| Reconnect & resume | EventSource auto-reconnects, replays Last-Event-ID | write it yourself |
| Backpressure | h2 per-stream flow control | monitor bufferedAmount |
Last-Event-ID deserves emphasis: SSE resume is a browser built-in and a server-side one-liner. Every WebSocket app that cares about messages missed during a reconnect writes that machinery by hand, and most write it wrong.
Brotli's ratio isn't free — the codec context lives per connection. 2,000 idle connections, measured as RSS delta.
| Configuration | RSS / conn | Extrapolated @ 50k |
|---|---|---|
| WebSocket · no compression | 56.8 KB | 2.7 GB |
| SSE · no compression | 92.2 KB | 4.4 GB |
| WebSocket · deflate | 430.3 KB | 20.5 GB |
| SSE · gzip | 458.0 KB | 21.8 GB |
| SSE · brotli lgwin=17 (128 KB) | 923.4 KB | 44.0 GB |
| SSE · brotli lgwin=22 (4 MB, default) | 942.2 KB | 44.9 GB |
I expected lgwin=17 to be dramatically cheaper than lgwin=22, since Test 6 shows 17 already saturates the ratio. It isn't — 923 KB vs 942 KB, flat within noise. Node's brotli sizes its ring buffer against the input it actually sees, not the declared window. So “tune lgwin down to save RAM” is wrong. Tune it down anyway for the ratio; expect no memory win from it.
Compression itself is the 5–10× memory multiplier, not the choice of window — and it isn't a WebSocket-vs-SSE axis at all: ws deflate and sse gzip are the same zlib bill (430 vs 458 KB).
So the honest shape of the trade: fat morphs plus brotli buys a 48× byte reduction at roughly 10× the per-connection memory. Excellent for a few thousand concurrent editors; bad for 50k idle viewers. Above that scale you send narrow patches, drop to gzip, or terminate compression at an edge tier instead of in the origin process.
Reach for a WebSocket when you're sending small binary or JSON messages at high frequency (sustained >20/s), you need strict cross-command ordering without thinking about it, or you need true push to a client that also streams continuously — games, cursors, audio. None of those describe an HTML-over-the-wire CRUD app.
Reach for SSE + CQRS when the payload is HTML, the write rate is human-scale, and you want auth, logging, caching, and resume to be things you already have rather than things you build.
“Bidirectional” was never the property you needed a WebSocket for. The client-to-server channel is HTTP, and it always was — the article's own framing, that with SSE “if the client wants to send something, there is no channel for it,” is the load-bearing error. What actually varies by use case is payload shape and message rate, and for HTML specifically, SSE is the faster transport, not merely the simpler one.