Skip to content

HTML over the wire

WebSockets for HTML over the wire? In this economy?

drk
Aug 11, 20265 min read
2

ame 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 where WebSockets can't.

The appeal of hypermedia is that you stop diffing on the server or 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:

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions