We're fighting over the wrong wire: WebSockets vs. SSE for HTML over the wire

andros1 pts0 comments

We're fighting over the wrong wire: WebSockets vs SSE for HTML over the wire | Andros Fenollosa

Skip to content

A while ago I published HTML over WebSockets. A reader from the Datastar community, who goes by drk, replied with HTML over the wire, live demo included. Nicely done. He argues that to ship HTML, SSE + POST over HTTP/2 beats WebSockets, and backs it with a few technical points. We actually agree on almost everything: SPAs carry too much complexity, and hypermedia is the way out. We disagree on the wire. And the more I look at his demo, the clearer it gets: we're fighting over the wrong wire.

Compression, with a catch

The centerpiece of the reply is a demo: a 66 KB HTML table re-rendered on every keystroke, sent over both transports at once. The WebSocket costs 3.4 KB per patch, SSE 23 bytes. 153 times apart. The number is real. But there's a catch.

It re-renders, and measures, a huge blob on every keystroke (what the demo calls fat-morphing the whole log) and sends it whole every time. Which is exactly what HTML-over-WebSockets frameworks are built not to do.

His demo has a toggle, "narrow", that sends just what changed instead of the whole document. Flip it: WebSocket 90 B, SSE 80 B. The gap vanishes. It isn't measuring WebSocket against SSE, it's measuring resending the whole document against sending only the change .

drk sharpens this well: hypermedia re-renders instead of diffing, so each push looks almost like the last, which is exactly what compression loves, as long as it can still see the previous copy. His demo shows that with large documents DEFLATE's bounded window loses sight of the previous copy and Brotli's (up to 16 MB) does not. I concede it fully, it's true.

But below that window, permessage-deflate already does the same trick the reply credits to Brotli. With context takeover , on by default (RFC 7692), the compressor keeps its window alive across messages and compresses each patch against the previous one. Send small patches and you stay in that sweet spot.

And here is the deeper part the reply misses: a WebSocket isn't condemned to DEFLATE either . A frame is binary, it carries whatever you throw at it. You can Brotli-compress the HTML on the server and decode it on the client with DecompressionStream, which today ships brotli natively in Chromium and Safari 18.4 (only Firefox still needs a polyfill). SSE gets Brotli automatically; a WebSocket gets it if you wire it up. The line was never "can or can't", it's "free or by hand".

In short, it isn't a structural problem, it's a design one.

The real argument isn't the wire, it's the architecture

If the byte count ties as soon as you send patches, then let's talk about the architectures each wire imposes. That's where the real differences are:

A single, ordered, stateful channel on the server, which is the LiveView model over WebSocket.

A split CQRS : an SSE stream coming down, plus POST requests going up, which is the reply's model.

Here it is, drawn out:

sequenceDiagram<br>participant C as Browser<br>participant S as Server<br>rect rgb(245, 238, 230)<br>note over C,S: SSE + POST, two channels you have to stitch<br>C->>S: GET /events (SSE stream, stays open)<br>C->>S: POST /command (another request, another stream)<br>S->>S: process, then find that session's SSE stream<br>S-->>C: push the HTML down the right SSE stream<br>end<br>rect rgb(230, 240, 245)<br>note over C,S: WebSocket, one ordered full-duplex channel<br>C->>S: command (frame, ~80 B)<br>S->>S: process<br>S-->>C: HTML back down the same channel, in order<br>end<br>drk points out that the SSE stream is one-way but your app isn't, and that every command over a POST gets authentication, rate limiting and logging for free, while a WebSocket forces you to reinvent all of that. It's his best argument. And he's partly right. But look at what the other side costs.

You don't reinvent authentication, you do it once. A WebSocket handshake is a normal HTTP request that carries your cookies through the same middleware. In Django Channels, AuthMiddlewareStack reads the session cookie from the handshake and hands your consumer the same scope["user"] as always. You authenticate at the door and the identity rides the socket for its whole life. The SSE + POST model re-runs that middleware and re-sends those cookies on every command, the header overhead the reply told us to stop worrying about, now paid per keystroke.

Two channels carry a correlation cost. The POST arrives on one stream, and its result has to be pushed back down the right SSE stream for the right session. drk's demo needs "one session, one in-memory SQLite and one renderer" shared between the two precisely to stitch that together. A WebSocket is one ordered channel: the reply comes back where the request left, in order, with no correlation table.

For traffic that goes up small and often, the frame wins. Typing indicators, presence, validation on every keystroke, collaborative cursors. A WebSocket frame is 2 to 14 bytes. A POST is a whole request:...

websocket wire html post stream demo

Related Articles