Nos estamos peleando por el cable equivocado: WebSockets vs SSE para HTML over the wire
Hace poco publiqué HTML sobre WebSockets. Un lector de la comunidad de Datastar, apodado drk, contestó con HTML over the wire con demo en vivo incluida, ¡muy elaborado! Defiende que para mandar HTML es mejor SSE + POST sobre HTTP/2 que WebSockets. Y para ello me da algunos argumentos técnicos. En realidad coincidimos en casi todo. Ambos indicamos que a las SPA les sobra complejidad y que la hipermedia es la salida. Discrepamos en el cable. Y cuanto más miro su demo, más claro lo tengo: nos estamos peleando por el cable equivocado.
Compresión con truco
El plato fuerte de la réplica es una demo con una tabla HTML de 66 KB que se vuelve a renderizar en cada pulsación, enviada por los dos transportes a la vez. El WebSocket cuesta 3,4 KB por parche, SSE 23 bytes. 153 veces de diferencia. El dato es real. Sin embargo, tiene trampa.
Re-renderiza, y mide, un bloque enorme en cada tecla (también llamado Morphing del log completo); lo envía entero cada vez. Que es justo lo que los frameworks de HTML sobre WebSockets están diseñados para no hacer.
En su demo tienes un interruptor, "parche estrecho", que en vez del documento entero manda solo lo que cambia. Al activarlo: WebSocket 90 B, SSE 80 B. La diferencia se esfuma. No mide WebSocket contra SSE, mide reenviar el documento entero contra enviar solo el cambio.
drk lo afina bien: la hipermedia re-renderiza en lugar de diffear, así que cada envío se parece al anterior, que es lo que le gusta a la compresión si todavía puede ver la copia anterior. Su demo demuestra que, con documentos grandes, la ventana de DEFLATE (acotada) pierde de vista la copia anterior y la de Brotli (hasta 16 MB) no. Lo concedo entero, es cierto.
Pero por debajo de esa ventana, permessage-deflate ya hace el mismo truco que la réplica le atribuye a Brotli. Con context takeover, activado por defecto (RFC 7692), el compresor mantiene viva su ventana entre mensajes y comprime cada parche contra el anterior. Manda parches pequeños y estás siempre en esa zona buena.
Y aquí está la parte profunda que a la réplica se le escapa: un WebSocket tampoco está condenado a DEFLATE. Un frame es binario, lleva lo que le eches. Puedes comprimir el HTML con Brotli en el servidor y descomprimirlo en el cliente con DecompressionStream, que hoy trae brotli nativo en Chromium y en Safari 18.4 (solo Firefox aún pide un polyfill). SSE se lleva Brotli automático; un WebSocket lo lleva si se lo pones. La frontera nunca fue "puede o no puede", sino "gratis o a mano".
En resumen, no es un problema estructural sino de diseño.
La discusión de verdad no es el cable, es la arquitectura
Si el tamaño de los bytes empata en cuanto mandas parches, hay que hablar de los modelos de arquitectura que cada cable impone. Y ahí sí que hay diferencias:
- Un canal único, ordenado y con estado en el servidor, que es el modelo LiveView sobre WebSocket.
- Un CQRS partido: un stream SSE que baja, más peticiones POST que suben, que es el modelo de la réplica.
Míralo dibujado:
sequenceDiagram
participant C as Navegador
participant S as Servidor
rect rgb(245, 238, 230)
note over C,S: SSE + POST, dos canales que hay que coser
C->>S: GET /events (stream SSE, queda abierto)
C->>S: POST /command (otra petición, otro stream)
S->>S: procesa y localiza el stream SSE de esa sesión
S-->>C: empuja el HTML por el stream SSE correcto
end
rect rgb(230, 240, 245)
note over C,S: WebSocket, un canal ordenado full-duplex
C->>S: comando (frame, ~80 B)
S->>S: procesa
S-->>C: HTML de vuelta por el mismo canal, en orden
end
drk dice que el stream SSE es unidireccional pero tu app no, y que cada comando por un POST recibe autenticación, rate limiting y logging gratis, mientras que un WebSocket te obliga a reinventar todo eso. Es su mejor argumento. Y tiene una parte de razón. Pero mira lo que cuesta el otro lado.
No reinventas la autenticación, la haces una vez. Un handshake WebSocket es una petición HTTP normal que lleva tus cookies por el mismo middleware. En Django Channels, AuthMiddlewareStack lee la cookie de sesión del handshake y le da a tu consumer el mismo scope["user"] de siempre. Autenticas en la puerta y la identidad viaja sobre el socket toda su vida. El modelo SSE + POST vuelve a ejecutar ese middleware y reenvía esas cookies en cada comando, la sobrecarga de cabeceras que la propia réplica nos dijo que dejáramos de mirar, ahora pagada por pulsación.
Dos canales tienen un coste de correlación. El POST llega por un stream y su resultado hay que empujarlo de vuelta por el stream SSE correcto de la sesión correcta. La demo de drk necesita "una sesión, una SQLite en memoria y un renderizador" compartidos entre los dos precisamente para coser eso. El WebSocket es un canal ordenado, la respuesta vuelve por donde salió la petición, en orden, sin tabla de correlación.
Para el tráfico que sube mucho y pequeño, el frame gana. Indicadores de escritura, presencia, validación en cada tecla, cursores colaborativos. Un frame WebSocket son de 2 a 14 bytes. Un POST es una petición entera: cabeceras, stream nuevo, la vuelta completa por el middleware. Para un flujo de mensajitos, el canal siempre abierto es el ligero.
Y el broadcast sale gratis. Como decía en mi artículo original, un proceso por cliente con estado en el servidor hace que empujar a todos a la vez sea trivial. Un chat, un panel en vivo, un juego; el mismo modelo. SSE también empuja, sí, pero la vuelta del cliente ya no vive en el mismo sitio.
Y no es tecnología frágil ni de laboratorio. Phoenix sostiene más de 2 millones de conexiones WebSocket en una sola máquina, un proceso ligero por cliente, y Discord da tiempo real a millones de personas sobre el mismo modelo. Elegir este cable no es tirarse a la piscina.
Así que la pregunta no es "¿qué cable comprime mejor?". Es "¿tu app empuja, o habláis los dos?". Si es una conversación con estado, el canal único es el que encaja. Si es un formulario que envía y renderiza, no lo necesitas.
Dónde SSE gana de verdad
Y precisamente por eso no vengo a enterrar SSE. Para media web, SSE es la elección correcta, y lo dije en el artículo original.
- Es más simple y más barato de operar. Sin proceso con estado por cliente, más fácil de balancear y escalar. Si tu flujo es sobre todo servidor a cliente (notificaciones, un feed, un panel, los tokens de una respuesta de IA), SSE sobra y ahorra.
- Se lleva Brotli gratis y automático. Un stream SSE es una respuesta HTTP, así que aprovecha el content coding del navegador sin que hagas nada. Un WebSocket puede llevar Brotli, como vimos arriba, pero se lo montas tú; automático solo tiene
permessage-deflate, y nadie publicó unpermessage-brotliestándar. En comodidad, punto para SSE. - Viaja sobre la conexión HTTP/2 de la página. Un WebSocket, salvo que todo el camino hable RFC 8441 (o su equivalente para HTTP/3, RFC 9220), abre la suya. Aunque conviene recordar el reverso: sobre HTTP/1.1, SSE gasta una de las seis conexiones por origen del navegador y se estrangula con varias pestañas; su viaje gratis exige HTTP/2. En cuanto estás en HTTP/2, el argumento de la conexión queda casi en tablas.
Está bien pensado, y si tu app es de empuje puro, SSE es la elección correcta. Pero si tu app es de conversación, con estado y colaboración, el cable correcto es WebSocket.
Entonces, ¿por qué cable?
Vuelvo al principio, con las cartas boca arriba. drk empuja SSE porque construye sobre SSE. Yo empujo WebSockets porque construí Django LiveView sobre WebSockets. Pero cuando bajas al detalle, la pelea del cable se deshace:
- El tamaño de los bytes no separa a WebSocket de SSE. Separa documento entero de parche. Manda el parche y empatan.
- Lo que de verdad separa a los dos modelos es la arquitectura: un canal único con estado contra un CQRS partido. Y eso lo decide tu problema, no tu framework.
Y si aun así tienes dudas, te invito a jugar con la demo de drk. Activa el parche estrecho. Y luego decide.
Fuentes
- htmlwire.exe.xyz, la demo de drk, medida a mano: al activar el parche estrecho, 90 B (WebSocket) vs 80 B (SSE).
- RFC 7692, Compression Extensions for WebSocket:
permessage-deflatey el context takeover. - Phoenix.LiveView.Engine: los diffs llevan solo los valores dinámicos cambiados, sin HTML entero.
- DecompressionStream: decodificación nativa de
brotlien Chromium y Safari 18.4. - Autenticación en Django Channels:
AuthMiddlewareStackyscope["user"]desde el handshake. - WebSocket vs HTTP: de 2 a 14 bytes de framing por mensaje.
- RFC 8441 y RFC 9220: arranque de WebSockets sobre HTTP/2 y HTTP/3 en la conexión compartida.
- MDN, Using server-sent events: el límite de seis conexiones por host en HTTP/1.1.
- The Road to 2 Million WebSocket Connections in Phoenix: escala del modelo de un proceso por conexión.
- Compresión con truco
- La discusión de verdad no es el cable, es la arquitectura
- Dónde SSE gana de verdad
- Entonces, ¿por qué cable?
- Fuentes
Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.
Ayúdame a seguir escribiendo
Cada café me da un empujón para escribir el siguiente artículo.
¡Claro, te invito!
Comentarios
Todavía no hay ningún comentario.