- JavaScript 54%
- HTML 46%
SSE + CQRS vs WebSockets, for HTML over the wire #
A stress test of the claim that WebSockets are the right transport for "real-time SPAs with barely any JavaScript", as argued in html-over-websockets.
Theory under test: the transport is not the bottleneck. A CQRS split — reads down an SSE stream, writes up as ordinary HTTP POSTs — is simpler than a WebSocket and just as fast, provided you are on HTTP/2.
Verdict: confirmed, with one correction in each direction.
- The article's central mechanical claim — that a persistent connection "avoids repeating the TCP handshake and the HTTP headers on every interaction" — is true on HTTP/1.1 and false on HTTP/2. Measured: zero new TCP connections for 3,000 command POSTs.
- But SSE's real advantage is not latency parity, it is compression. For repeated HTML, SSE beats WebSockets by up to 48× on bytes, and the gap is structural: WebSocket cannot reach it, at any effort.
- SSE's real cost is one extra round trip to establish, and (if you use brotli) per-connection codec memory.
Chat is not the dividing line. Payload shape is.
Published as a write-up and reference — something to read and take ideas from, not a library to depend on. See LICENSE.
What's in this repo #
bench/, src/ |
the measurement harness — a parameterized server, a TCP-level delay proxy, and seven benchmarks |
results/ |
captured output from those benchmarks, quoted throughout this file |
demo/ |
a live two-pane demo app you can run and click. See demo/README.md |
report.html |
a standalone write-up page of the findings |
LICENSE |
Beerware |
demo/public/idiomorph.js is vendored Idiomorph
(0BSD), used by both panes so DOM-update cost cannot be mistaken for a
transport difference.
The harness and the demo each have their own server on purpose: the harness one is CLI-parameterized for automated A/B runs, the demo one is session-aware and SQLite-backed. Unifying them would make both harder to read.
Running it #
The harness talks TLS to itself, so generate a throwaway self-signed cert first (it is gitignored — never commit one):
mkdir -p certs && openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout certs/key.pem -out certs/cert.pem \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
npm install
node bench/rtt.js # command -> patch latency, across RTT tiers
node bench/bytes.js # bytes on the wire, below TLS
node bench/compression.js # the compression asymmetry
node bench/window.js # why that asymmetry exists
node bench/ordering.js # ordering + the write/read race
node bench/idle.js # cost of holding connections open
node bench/compress-memory.js # what the compression win costs in RAM
Both architectures share src/domain.js, so the HTML is byte-identical and
the only variable is the wire. A TCP-level delay proxy (src/tcp-delay.js)
sits below TLS so that handshakes pay real latency — which is the whole point,
since loopback benchmarks hide exactly the cost being argued about.
1. The transport is not the bottleneck #
bench/rtt.js — 100 clients × 30 rounds, TLS, single-row patches. Time from
dispatch() to the patch carrying that command's nonce arriving on the
client's own stream.
| RTT | variant | connect p50 | 1st cmd p50 | steady p50 | steady p95 | steady p99 | new TCP conns |
|---|---|---|---|---|---|---|---|
| 0ms | ws | 1.54 | 2.49 | 1.44 | 2.89 | 3.56 | 0 |
| 0ms | sse+POST h2 | 1.67 | 5.04 | 2.00 | 3.83 | 4.45 | 0 |
| 0ms | sse+POST h1 | 1.47 | 44.37 | 0.74 | 2.13 | 2.53 | 100 |
| 25ms | ws | 56.6 | 29.99 | 27.34 | 29.44 | 30.13 | 0 |
| 25ms | sse+POST h2 | 84.33 | 38.63 | 27.96 | 30.31 | 31.27 | 0 |
| 25ms | sse+POST h1 | 56.93 | 100.94 | 26.52 | 29.44 | 29.83 | 100 |
| 100ms | ws | 208.12 | 107.8 | 103.49 | 106.21 | 107.57 | 0 |
| 100ms | sse+POST h2 | 314.46 | 116.29 | 105.42 | 109.13 | 110.35 | 0 |
| 100ms | sse+POST h1 | 211.58 | 259.1 | 102.51 | 105.08 | 105.84 | 100 |
At 100ms RTT the spread between all three is under 3ms on a 103ms round trip — under 3% — and the ordering is not even stable (h1 is nominally fastest). At 0ms RTT the transport costs ~1.4–2.0ms, and that gap is Node's h2 framing, not something a user can perceive.
The theory holds. Steady-state latency is the network, not the transport.
The handshake claim, isolated #
The "1st cmd" column is the article's claim measured directly — the first command after connect, when an HTTP/1.1 client must open a fresh socket:
- HTTP/1.1: real. 44ms vs 2.5ms at 0 RTT; 259ms vs 108ms at 100ms RTT. That's ~2.4× and it is exactly TCP + TLS. The article is right about h1.
- HTTP/2: gone. 116ms vs 108ms at 100ms RTT — a fraction of one RTT, not a
handshake.
new TCP conns= 0 across 3,000 POSTs. The POST is a new h2 stream on the connection the SSE stream is already using.
This is the load-bearing correction. The article generalizes an HTTP/1.1 property to HTTP in general.
The cost SSE actually pays: connection setup #
Look at connect p50 at 100ms: ws 208ms, sse+h2 314ms. That is one extra
round trip, and it is real — h2 exchanges SETTINGS before the request goes
out, where a WebSocket upgrade rides the first HTTP request. You pay it once
per page load, not per interaction.
2. HTTP/2 multiplexing, verified #
Counted at the TCP layer, during the command phase only (after connect):
| variant | new TCP connections for 3,000 commands |
|---|---|
| ws | 0 |
| sse+POST h2 | 0 |
| sse+POST h1 | 100 (one per client) |
Confirmed: over h2 the SSE stream and every command POST share one TCP connection and one TLS session. No handshake, no connection-per-write, and no consumption of the browser's 6-connection-per-origin budget — which on HTTP/1.1 an SSE stream would eat one of, permanently.
3. Bytes on the wire #
bench/bytes.js, counted below TLS, per command round trip (up + down),
measured after connect so this is marginal steady-state cost.
Single-row patch (~500 B of HTML):
| compress | variant | up/op | down/op | total/op |
|---|---|---|---|---|
| none | ws | 68 B | 533 B | 601 B |
| none | sse+POST h2 | 88 B | 546 B | 634 B |
| none | sse+POST h1 | 192 B | 709 B | 902 B |
| br | ws | 35 B | 44 B | 79 B |
| br | sse+POST h2 | 87 B | 84 B | 171 B |
| br | sse+POST h1 | 192 B | 224 B | 417 B |
Uncompressed, the h2 CQRS split costs 33 bytes per interaction more than a
WebSocket. That is the entire "HTTP headers on every interaction" penalty
after HPACK: the POST's :method, :path, and content-type are indexed to
almost nothing after the first request. On HTTP/1.1 it is 301 bytes — real,
and again the article is describing h1.
For small patches WebSocket does win on bytes (79 B vs 171 B). It is 92 bytes.
Whole-board morph (~88 KB of HTML) — this is where it inverts:
| compress | variant | up/op | down/op | total/op |
|---|---|---|---|---|
| none | ws | 68 B | 93,774 B | 93,842 B |
| none | sse+POST h2 | 192 B | 88,676 B | 88,868 B |
| gzip | ws | 35 B | 3,622 B | 3,657 B |
| gzip | sse+POST h2 | 92 B | 3,656 B | 3,749 B |
| br | ws | 35 B | 3,622 B | 3,657 B |
| br | sse+POST h2 | 87 B | 90 B | 177 B |
Same request for brotli, 20× the bytes on WebSocket. Note that the WS
gzip and br rows are identical — that is not a bug.
4. Why WebSocket cannot close that gap #
bench/compression.js — 100 patches, isolated from the network.
Whole-board patches, 8,639 KB raw:
| codec | available to | total | avg/patch | warm patch | ratio | encode cpu |
|---|---|---|---|---|---|---|
| none | both | 8638.7 KB | 88,460 B | 88,559 B | 1.0× | 0 ms |
| deflate (ctx takeover) | WS permessage-deflate, default | 384.9 KB | 3,941 B | 4,256 B | 22.4× | 27.3 ms |
| deflate (NO ctx takeover) | WS when a proxy forces it | 439.1 KB | 4,496 B | 4,780 B | 19.7× | 18.4 ms |
| gzip stream | SSE Content-Encoding: gzip |
384.9 KB | 3,941 B | 4,256 B | 22.4× | 23.3 ms |
| brotli q4 (w22) | SSE Content-Encoding: br |
8.0 KB | 82 B | 52 B | 1075× | 8.2 ms |
| brotli q5 (w24) | SSE Content-Encoding: br |
6.6 KB | 68 B | 49 B | 1301× | 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 is emitting back-references instead of doing fresh Huffman work.
Three structural reasons, and none of them are tunable on the WS side:
a. No brotli exists for WebSocket. 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 just works, and every browser has decoded it natively
since 2016.
b. DEFLATE's window is capped at 32 KB by the format. bench/window.js
sweeps the window against an 86.3 KB board:
| codec | window | warm patch | |
|---|---|---|---|
| deflate w=13 | 8 KB | 4,014 B | |
| deflate w=15 | 32 KB | 3,966 B | DEFLATE FORMAT MAXIMUM — WebSocket stops here |
| brotli lgwin=16 | 64 KB | 2,750 B | window < one board |
| brotli lgwin=17 | 128 KB | 39 B | window ≥ one board — previous copy is reachable |
| brotli lgwin=22 | 4 MB | 39 B |
The cliff is at the payload size, and it is a cliff: 2,750 B → 39 B between 64 KB and 128 KB of window. If DEFLATE cannot see the previous copy of your patch, a fat morph re-compresses from scratch every time, while brotli emits "same as last time, with one row different." WebSocket is on the wrong side of a limit written into the DEFLATE format in 1996.
The practical threshold is half the window, not the whole window. The live
demo (demo/) makes this precise: to match copy N against copy N−1, the
window has to hold both, so DEFLATE's usable payload ceiling is ~16 KB, not
32 KB. Growing the chat log and watching the per-patch cost confirms it:
| log size (morph HTML) | WS wire/patch | SSE wire/patch | gap |
|---|---|---|---|
| 4.1 KB | 80 B | 65 B | 1.2× |
| 9.3 KB | 176 B | 66 B | 2.7× |
| 14.4 KB | 325 B | 68 B | 4.8× |
| 19.6 KB | 2.6 KB | 68 B | 39× |
| 24.8 KB | 3.7 KB | 67 B | 56× |
| 31.1 KB | 4.6 KB | 68 B | 69× |
SSE stays flat at ~67 bytes per patch no matter how large the document gets. WebSocket's cost tracks the document size once it crosses the threshold. The break lands between 14.4 KB and 19.6 KB — exactly where two consecutive copies stop fitting in 32 KB.
c. Context takeover is a memory bill proxies refuse to pay. Keeping the
zlib dictionary alive between messages is what gets WS to even 22×; without it
the narrow-patch case collapses from 10.0× to 1.8×. It costs ~256 KB per
connection, so servers and proxies routinely negotiate no_context_takeover
to bound memory.
And to match, you would do work the browser already does. SSE + brotli is decoded in 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 run it per message on the main thread — reimplementing a codec every browser already ships, to reach a ratio SSE gets from one response header.
5. Ordering and the write/read race — the honest cost #
This is where splitting the transport genuinely costs something. A WebSocket
is one ordered byte stream, so commands apply in send order for free.
bench/ordering.js fires bursts of 10 un-awaited commands:
| RTT | variant | out-of-order pairs | delivered | ack→patch p50 |
|---|---|---|---|---|
| 0ms | ws | 0% | 400/400 | — (no ack exists) |
| 0ms | sse+POST h2 | 0% | 400/400 | 0.20 ms |
| 0ms | sse+POST h1 | 2.11% | 400/400 | 0.03 ms |
| 100ms | ws | 0% | 400/400 | — |
| 100ms | sse+POST h2 | 0% | 400/400 | 0.14 ms |
| 100ms | sse+POST h1 | 0% | 400/400 | −0.12 ms |
- h2 preserved order in every trial. Concurrent POSTs are multiplexed onto one ordered TCP connection, so they arrive at the server in send order.
- h1 reordered 2.11% of pairs at 0 RTT — the 5-socket pool races, and this is a real correctness hazard if commands are not commutative.
- The ack→patch race is real but sub-millisecond: the POST's 204 lands ~0.14ms before the patch it caused. Code that re-enables a button on POST success will run just before the DOM updates. Drive UI off the patch.
Worth naming: the WS "ack" column is empty 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, which is a point for the CQRS split, not against it.
6. Ergonomics: where the connection object lives #
This is the argument that does not need a benchmark — the two implementations
in src/server.js make it plain.
Both architectures need the same subscriber registry (rooms, a
Map<room, Set<sub>>). The article implies WS needs one and SSE does not;
that is false, and the code is shared verbatim.
The difference is what the write path touches:
// 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), ++eventId))
}
handleCommand is a pure function of (room, id, nonce). It is 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 into the registry anyway,
and you have re-derived the CQRS split with extra steps.
Everything else the write path gets for free by being an HTTP request:
| SSE + POST | WebSocket | |
|---|---|---|
| auth | existing cookie/session middleware | re-implement inside 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, CDN analytics | one opaque long-lived connection |
| errors | status codes | invent a convention |
| reconnect + resume | EventSource auto-reconnects, replays Last-Event-ID |
write it yourself |
| caching / CDN | ordinary HTTP | not applicable |
| 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 missed messages
during a reconnect writes that machinery by hand, and most write it wrong.
7. What it costs to hold connections open #
bench/idle.js — idle connections, no compression, RSS delta per connection.
The 4,000-connection rows are the trustworthy ones; at 1,000 the allocator's
arena behaviour dominates.
| variant | conns | RSS/conn | heap/conn | conn/s | idle CPU |
|---|---|---|---|---|---|
| ws (plain) | 4000 | 23.8 KB | 3.0 KB | 5,697 | 747 ms |
| sse h2 (plain) | 4000 | 86.6 KB | 5.4 KB | 7,189 | 644 ms |
| sse h1 (plain) | 4000 | 35.4 KB | 5.2 KB | 7,883 | 579 ms |
| ws (tls) | 4000 | 248.5 KB | 5.5 KB | 1,314 | 2,988 ms |
| sse h2 (tls) | 4000 | 245.1 KB | 8.0 KB | 1,387 | 2,979 ms |
In production configuration the difference disappears. With TLS — the only way either of these ships — SSE/h2 costs 245 KB per connection and WebSocket costs 248 KB. TLS buffers dominate both by an order of magnitude, and idle CPU is identical (2,979 vs 2,988 ms). SSE even accepts connections slightly faster.
The h2 penalty is only visible in plaintext (86.6 vs 23.8 KB), where h2's per-stream framing state has nothing to hide behind. Nobody runs plaintext.
The compression win has a bill, and it is per-connection #
bench/compress-memory.js — 2,000 idle connections, compression enabled:
| variant | RSS/conn | extrapolated @ 50k conns |
|---|---|---|
| ws no compression | 56.8 KB | 2.7 GB |
| sse no compression | 92.2 KB | 4.4 GB |
| ws deflate (no ctx takeover) | 427.1 KB | 20.4 GB |
| ws deflate (ctx takeover) | 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=20 (1 MB) | 943.4 KB | 45.0 GB |
| sse brotli lgwin=22 (4 MB, default) | 942.2 KB | 44.9 GB |
| sse brotli lgwin=24 (16 MB) | 895.5 KB | 42.7 GB |
Two things here, one of which refuted my own hypothesis:
- Brotli memory does not scale with
lgwin. I expectedlgwin=17to be much cheaper than22and it is not — 923 KB vs 942 KB, flat within noise. Node's brotli sizes its ring buffer against the input actually seen, not the declared window. So "tunelgwindown to save RAM" is wrong. Tune it down anyway (Test 6 shows 17 already saturates the ratio) but expect no memory win from it. - Compression itself is the 5–10× memory multiplier, not the choice of
window. Brotli costs ~2× deflate per connection, and deflate costs ~7× no
compression.
ws deflateandsse gzipare the same zlib bill (430 vs 458 KB) — this cost is not a WebSocket-vs-SSE axis at all.
So the honest shape of the tradeoff: fat morphs + brotli buys a 48× byte reduction at roughly 10× the per-connection memory. That is an excellent trade for a few thousand concurrent editors and a bad one for 50k idle viewers. Above that scale you either send narrow patches, drop to gzip, or terminate compression at an edge/CDN tier instead of in the origin process.
Conclusions #
- The theory is right. Transport contributes ~2ms; at any real RTT the three architectures are within 3% of each other, and per-connection cost under TLS is a wash (245 vs 248 KB). Choose on ergonomics.
- The article's handshake argument is an HTTP/1.1 argument. On h2 the command POST opens zero connections and costs ~33 bytes of headers.
- Chat is a red herring. "Bidirectional" is not a transport property you need a WebSocket for — the client-to-server channel is HTTP, and it always was. What actually varies by use case is payload shape and message rate.
- For HTML specifically, SSE is the faster transport, not merely the simpler one — because HTML repeats, and only SSE can point a large compression window at it.
- Prefer WebSocket when you are sending small binary/JSON messages at high frequency (>20/s sustained), you need strict cross-command ordering without thinking about it, or you need true server-push to a client that also streams continuously (games, cursors, audio). None of those describe an HTML-over-the-wire CRUD app.
- Prefer 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.