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

Leer en español

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
    participant C as Browser
    participant S as Server
    rect rgb(245, 238, 230)
    note over C,S: SSE + POST, two channels you have to stitch
    C->>S: GET /events (SSE stream, stays open)
    C->>S: POST /command (another request, another stream)
    S->>S: process, then find that session's SSE stream
    S-->>C: push the HTML down the right SSE stream
    end
    rect rgb(230, 240, 245)
    note over C,S: WebSocket, one ordered full-duplex channel
    C->>S: command (frame, ~80 B)
    S->>S: process
    S-->>C: HTML back down the same channel, in order
    end

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: headers, a new stream, the full round trip through the middleware. For a stream of tiny messages, the always-open channel is the light one.

And broadcast comes for free. As I said in the original article, one stateful process per client makes pushing to everyone at once trivial. A chat, a live dashboard, a game: the same model. SSE pushes too, sure, but the client's reply no longer lives in the same place.

And this is neither fragile nor lab-only technology. Phoenix holds more than 2 million WebSocket connections on a single box, one lightweight process per client, and Discord serves real-time to millions of people on the same model. Choosing this wire isn't a leap in the dark.

So the question isn't "which wire compresses better?". It's "does your app push, or are both ends talking?". If it's a stateful conversation, the single channel is the one that fits. If it's a form that submits and renders, you don't need it.

Where SSE genuinely wins

And that's exactly why I'm not here to bury SSE. For half the web, SSE is the right call, and I said so in the original article.

  • It's simpler and cheaper to operate. No stateful process per client, easier to load-balance and scale. If your flow is mostly server to client (notifications, a feed, a dashboard, the tokens of an AI response), SSE is plenty and saves money.
  • It gets Brotli for free and automatically. An SSE stream is an HTTP response, so it rides the browser's built-in content encoding without you doing anything. A WebSocket can carry Brotli, as we saw above, but you wire it up yourself; automatically it only has permessage-deflate, and nobody shipped a standard permessage-brotli. On convenience, point to SSE.
  • It rides the page's HTTP/2 connection. A WebSocket, unless the whole path speaks RFC 8441 (or its HTTP/3 equivalent, RFC 9220), opens its own. Though it's worth remembering the flip side: over HTTP/1.1, SSE spends one of the browser's six connections per origin and chokes with several tabs; its free ride requires HTTP/2. Once you're on HTTP/2, the connection argument is close to a wash.

It's well thought out, and if your app is pure push, SSE is the right choice. But if your app is a conversation, with state and collaboration, the right wire is WebSocket.

So, which wire?

Back to the start, cards on the table. drk pushes SSE because he builds on SSE. I push WebSockets because I built Django LiveView on WebSockets. But when you get into the detail, the fight over the wire falls apart:

  • Byte size doesn't separate WebSocket from SSE. It separates whole document from patch. Send the patch and they tie.
  • What really separates the two models is the architecture: a single stateful channel against a split CQRS. And that's decided by your problem, not your framework.

And if you still have doubts, go play with drk's demo. Flip on the "narrow" patch. Then decide.

Sources

This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.

Help me keep writing

Every coffee gives me a push toward the next article.

Comments

There are no comments yet.

You may also like