Came across https://en.andros.dev/blog/ef4968f5/html-over-websockets-real-time-spas-with-barely-any-javascript/ today. Always appreciate folks exploring and challenging accepted norms generally, but with regard to modern web development. Was nodding my head up until…
✅ SPA is too complex
✅ Hypermedia (HTML over the wire)
🙈 Use WebSockets
So close!
OK so you totally can use WebSockets for HTML over the wire, but some of the rationale in the article was lacking IMO.
a single persistent connection avoids repeating the TCP handshake and the HTTP headers on every interaction
Connection overhead wasn't really the bottleneck here. Keep-alive means you pay the connection cost once and reuse it… not once per request. And the header overhead is a rounding error next to the HTML you're shipping.
So the argument misses that the page already holds open one connection and shares it across events and POSTs.
SSE + POST, over HTTP/2
1. TCP connect ──┐
2. TLS handshake ─┴─ once, for the page
3. GET / → stream 1 ← same connection
4. GET /app.css → stream 3 ← same connection
5. GET /events → stream 5 ← same connection, stays open
6. POST /command → stream 7 ← same connection
7. POST /command → stream 9 ← same connection
...A WebSocket can't ride that shared connection and needs it own.
1. TCP connect ──┐
2. TLS handshake ─┴─ once, for the page (h2)
3. GET / → stream 1
4. new WebSocket()
↓
5. TCP connect ───┐
6. TLS handshake ─┼─ a SECOND connection
7. HTTP/1.1 Upgrade ┘
8. frames...[SSE] It is one-way. Only the server pushes. If the client wants to send something, there is no channel for it: it has to make a separate HTTP request.
Yes the stream is one-way, but your app is not. Every command rides the same request life cycle and gets auth'd, rate limited, error handling/status codes, access logging… you end up reinventing all if not most of what your http server already gives you.
So: does your CRUD app or chat UI really need bi-directional communication? Is your UX a form, or actually requires a stream of data?
You know what camp I'm in, but maybe you're still wondering:
Which wire?
Taking it back to the intent of HTML over the wire I'd seriously consider going with SSE for a few reasons, but the main one is compression. Since SSE is just HTTP you can leverage all the affordances built in to browsers and significantly benefit from compression that WebSockets can't.
permessage-deflate, negotiated during the handshake via Sec-WebSocket-Extensions rather than set as a header. It uses raw DEFLATE (RFC 1951) — not gzip, which is DEFLATE plus a container. RFC 7692 (2015) was written as a framework: "This document defines a framework for creating WebSocket extensions that add compression functionality… This document also specifies one specific compression extension using the DEFLATE algorithm."permessage-* codecs. Nobody walked through it. IANA's registry has two entries total, and one of them isn't compression. Brotli is registered instead as an HTTP content coding (br, RFC 7932), which is why SSE gets it for free and WebSocket structurally cannot: SSE is an HTTP response, a WebSocket isn't.Why Can't WebSockets Do That?
Precisely: permessage-deflate, negotiated during the handshake via Sec-WebSocket-Extensions rather than set as a header. It uses raw DEFLATE (RFC 1951) — not gzip, which is DEFLATE plus a container. RFC 7692 (2015) was written as a framework: "This document defines a framework for creating WebSocket extensions that add compression functionality… This document also specifies one specific compression extension using the DEFLATE algorithm."
The door was left open for other permessage-* codecs. Nobody walked through it. IANA's registry has two entries total, and one of them isn't compression. Brotli is registered instead as an HTTP content coding (br, RFC 7932), which is why SSE gets it for free and WebSocket structurally cannot: SSE is an HTTP response, a WebSocket isn't.
The appeal of hypermedia is that you stop diffing on the client and just re-render. Send a whole page on every push and the numbers separate hard, because each copy looks almost exactly like the last one. Precisely what compression is good at, if it can still see the previous copy. Deflate can't past ~16 KB. Brotli can.
Any attempt to support better compression and you're fighting a losing battle since you'd have to rebuild and manage the decoder on the main thread or in a worker. You're paying bundle size and CPU to emulate an existing response header. I don't want to do that. Do you?
But don't take my word for it!
I built a demo where you can play around with both WebSockets and SSE powering the same UX.
https://htmlwire.exe.xyz
https://tangled.org/drk.wtf/htmlwire
Two panes, one server. Top talks over a WebSocket, bottom over SSE + POST. They share one session, one in-memory SQLite database, and one renderer.
Both get byte-identical HTML, and both apply it with Idiomorph — so DOM work can't be mistaken for a transport difference.
Anything you do in either pane updates both. Type on the left, watch the right redraw.
The server counts real wire bytes per transport — post-compression payload plus protocol framing — and pushes the readings to the dashboard over their own SSE stream.
How to drive it
Pick a workload in the left rail: chat or data grid.
Chat — send messages. Hit Fill log to jump the document past DEFLATE's window instead of typing 170 messages by hand.
Data grid — type in the filter (every keystroke re-renders all 240 rows), click a column header to sort, or add an issue. No warm-up needed; it's over the threshold from the first keystroke.
Compression: off — see the uncompressed baseline both transports start from.
Patch: narrow (chat only) — send just the changed row instead of the whole log.
Pop out either pane with the ↗ button. It opens as its own window with its own connection, and stays in sync through the server — no shared page, no postMessage, nothing up the sleeve.
Disclosure: The demo was built using a LLM. I hope that's obvious from how it's styled haha.
What do you think? Do WebSockets still seem like a good fit for this use case?
Here's some other resources on SSE and WebSockets if you're interested: