Live bake-off demo #
Deployed: https://htmlwire.exe.xyz
Two panes side by side. Left talks over a WebSocket, right over SSE + POST. They share one server, one session, one in-memory SQLite, and one renderer — acting in either pane updates both. The only difference is the wire, and the dashboard shows what that costs.
There are two workloads, and the conclusion flips between them:
- chat — a message is ~200 bytes. The transports are indistinguishable. This is the honest refutation: bi-directionality alone buys you nothing, and chat is not a reason to reach for WebSockets.
- data grid — 240 issues, filtered and sorted in SQLite, the whole table re-rendered on every keystroke. This is what hypermedia looks like when you stop diffing on the client, and it is where the wires come apart: 66 KB of identical HTML costs WebSocket ~3.5 KB and SSE ~30 B.
Use the workload switch in the left rail. The grid needs no warm-up; chat needs
Fill log (or ~170 messages) before the document outgrows DEFLATE's window.
node --no-warnings demo/server.js
open http://localhost:8088
Plain HTTP/1.1 by default, so there is no cert friction and both wires work in every browser.
--tls runs the page, the SSE stream and the command POSTs over HTTP/2
(https://localhost:8443), which is the configuration the multiplexing
argument is about. The catch is the self-signed cert: browsers frequently
refuse a wss:// upgrade on an untrusted certificate even after you accept
the interstitial for https:// — Safari always does — which silently kills the
WebSocket pane. If the WS pane sits on "wss refused", that's why; drop --tls.
Dropping TLS costs only the h2 multiplexing demonstration (already measured in the benchmarks). The compression divergence, which is the actual headline, is identical on HTTP/1.1.
PORT=9000 node demo/server.js if 8088 is taken.
What to actually do with it #
- Send a few messages from either pane. Watch both update, and note that the two wires cost the same — the document is far too small to separate them.
- Hit Fill log, or switch the workload to data grid and type in the filter. Either way the document crosses DEFLATE's ~16 KB ceiling.
- Watch
WS patchagainstSSE patchin the readout. SSE stays flat at tens of bytes whatever the document does; WebSocket starts tracking the document size and never stops.
That divergence is the whole thesis, happening live:
| log size | WS wire/patch | SSE wire/patch | gap |
|---|---|---|---|
| 4.1 KB | 80 B | 65 B | 1.2× |
| 14.4 KB | 325 B | 68 B | 4.8× |
| 19.6 KB | 2.6 KB | 68 B | 39× |
| 31.1 KB | 4.6 KB | 68 B | 69× |
DEFLATE's window is 32 KB, and to compress copy N against copy N−1 the window must hold both — so the usable ceiling is ~16 KB. Past that, WebSocket re-compresses the document from scratch on every patch. Brotli's window is 4 MB, so SSE keeps emitting "same as last time, one row different." Flip compression: off to see the uncompressed baseline both start from.
What's measured, and how #
Metrics are per session and per transport, counted server-side:
- wire bytes — post-compression payload plus real protocol framing. For
SSE the server owns the compressor, so this is exact. For WebSocket,
wsdoes permessage-deflate natively (as it should — the browser decodes it in the network stack) and the server mirrors the identical zlib context inDeflateMirrorto report the size, plus the 2/4/10-byte frame header. - html sent — bytes before compression. Identical on both sides by construction; if these two ever diverge, the demo is lying.
- bytes up — the command going the other way. WS pays a masked frame; SSE pays HPACK-compressed headers (~33 bytes on a warm h2 connection) plus body.
- conns — TCP connections. The SSE pane's stream and all its POSTs share the page's existing h2 connection. The WebSocket needs its own HTTP/1.1 connection, because browsers don't implement RFC 8441.
- send → patch — measured in the pane, from submit to the patch landing.
Deliberate fairness constraints #
- Both panes morph with Idiomorph, same call, same options — so DOM update cost can't be mistaken for a transport difference.
render.jsis shared. Both transports send byte-identical HTML.- Both use the compression that is genuinely best available to them: brotli for
SSE (
Content-Encoding: br), permessage-deflate with context takeover for WebSocket. There is no brotli option for WebSocket to choose.
Logs #
The server writes structured NDJSON to logs/htmlwire.ndjson (gitignored), one
event per line, so real interaction can be studied after the fact:
node demo/logstat.js # summarise it
LOG=off node demo/server.js # disable
LOG_ECHO=1 … # also mirror to stdout
LOG_DIR=/var/log/htmlwire … # somewhere else
Events: command (kind, render ms), patch (per transport: html bytes, wire
bytes, ratio, compress ms), broadcast (fanout, total ms), conn.open /
conn.close (protocol, compression, lifetime), reject (capacity, rate limit,
oversized body, no session), runtime (rss, cpu, streams, every 30s), and
rtt — submit-to-patch latency measured in the browser and beaconed back,
which is the one number no server-side counter can see.
Two deliberate omissions, because this runs publicly:
- Message bodies and filter queries are never written, only their lengths. Whatever strangers type into the demo is not ours to keep.
- Session ids are hashed to 8 hex characters. The sid is a signed bearer cookie; writing it to disk would put a live credential in a log file.
This is plain NDJSON rather than the OpenTelemetry SDK on purpose — the repo
has one runtime dependency and @opentelemetry/* would add dozens. The field
names and units follow OTel conventions, so a Collector filelog receiver can
ingest the file unchanged if you ever want it off the box.
Session scope, and what this demo is not #
Sessions are keyed by an HMAC-signed cookie minted per browser, so one visitor cannot join or write into another's session by choosing a cookie value. The signing key is per-process and ephemeral: restarting the server invalidates every session, which is the right lifetime for in-memory state.
What it deliberately does not have: accounts, authentication, per-IP limits, or persistence. Rate limiting and the connection caps are per-session, so a client rotating cookies can still consume more than its share. That is an accepted trade for a public toy with hard global caps — not a pattern to copy into something real.
Session storage #
Each browser session gets its own :memory: SQLite via node:sqlite, capped
at 300 rows; the server holds at most 40 sessions and evicts the coldest
subscriber-free one. The footer shows live row count, DB page bytes, and how
many sessions the process is holding.
Files #
server.js |
one server: SSE + POST + WS upgrade, plus --tls for h2 |
db.js |
per-session capped SQLite, LRU eviction, signed session ids |
render.js |
the shared renderer for both workloads — the fairness guarantee |
public/pane.js |
one pane, one transport; identical below the send/onPatch seam |
public/shell.js |
the dashboard: chart, bars, readings, controls |
public/shell.css, public/app.css |
dashboard and pane styling |
public/idiomorph.js |
vendored Idiomorph, 0BSD |
Metrics are pushed to the dashboard over their own SSE stream
(/metrics/stream), not polled — a 600 ms poll can only observe the last patch,
so bursts vanish. Watchers are kept out of the patch broadcast list, so the
dashboard can never inflate the numbers it reports.
Deployment #
Runs behind a TLS-terminating proxy that forwards to 127.0.0.1:8000, so the
app speaks plain HTTP while the browser still gets https:// and wss:// —
which is what makes the WebSocket pane work in production, where the local
self-signed cert failed.
The app binds loopback only; nothing but the proxy can reach it. Because it is a public service with no authentication, the systemd unit confines it:
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/srv/htmlwire/logs # the only writable path
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
MemoryMax=1G # above the app's own connection caps
TasksMax=256
MemoryDenyWriteExecute is deliberately absent — it breaks V8's JIT and the
process will not start with it.
Redeploy is an rsync of demo/ plus systemctl restart.
Why this host and not Cloudflare Workers #
The exe.dev proxy is transparent to both compression schemes, verified on the deployed instance:
content-encoding: bron the SSE stream passes through untouched, and the stream is not buffered (x-accel-buffering: nois honoured — first bytes arrive immediately and the connection stays open).- The WebSocket upgrade succeeds and negotiates
permessage-deflate, so WebSocket still gets its best case.
That second point is what keeps the comparison honest. Workers has no
permessage-deflate and its CompressionStream supports only
gzip/deflate/deflate-raw — no brotli — so a Workers deployment would have
measured an uncompressed WebSocket against a maybe-compressed SSE stream, and
flattered SSE for reasons that have nothing to do with the transports.