4dim / Notas
Tiempo real sin WebSockets: LISTEN/NOTIFY, SSE y el nginx que se lo traga
Del sondeo cada 8 segundos al aviso desde un trigger de Postgres empujado por Server-Sent Events. Por qué SSE y no WebSocket, por qué el canal no lleva contenido, y las cuatro directivas de nginx sin las que la ruta responde 200 y nunca entrega nada.
Del sondeo al aviso
Si atiendes clientes por chat, el retraso se nota: alguien escribe y su mensaje tarda en aparecer. Esta nota cuenta cómo pasamos de preguntar cada rato a que el servidor avise, y qué hubo que tocar para lograrlo.
La primera bandeja de Connection preguntaba al servidor cada 8 segundos si había algo nuevo. A eso se le llama sondeo. Un mensaje entrante tardaba, en promedio, 4 segundos en aparecer. Y cada pestaña abierta hacía 450 peticiones a la hora para, casi siempre, no recibir nada.
Es la forma más fácil de hacer «tiempo real» y la más cara por novedad entregada. La sustituimos por un aviso. Cuando entra un mensaje, Postgres da la voz con NOTIFY, el servidor la oye con LISTEN, y se la pasa al navegador por un canal que queda abierto.
El navegador, al recibir el aviso, vuelve a pedir la bandeja. El sondeo no desapareció: quedó a 45 segundos, como red de seguridad. Cubre el hueco entre que el canal se cae y el navegador lo vuelve a abrir.
El aviso nace en un trigger, no en el código
Los mensajes entran por varias puertas: el webhook de Meta, la dirección a la que Meta los entrega, el simulador de la cuenta de demostración y las integraciones que vengan. Si cada puerta tuviera que emitir el aviso, la próxima que alguien añada lo olvidaría.
Así que lo emite un disparador de la base, una regla que se ejecuta sola cada vez que se guarda un mensaje. Todas las puertas terminan guardando en la misma tabla, y ese guardado avisa.
SSE y no WebSocket
El canal es Server-Sent Events: un flujo abierto por el que el servidor manda texto al navegador. No es WebSocket, por dos razones.
La primera: el tráfico va en una sola dirección, del servidor al navegador. SSE es exactamente eso sobre HTTP normal, sin saludo previo aparte y sin nada nuevo que configurar en nginx.
La segunda casi nadie la cita. El navegador reconecta solo cuando el canal se corta, porque eso viene de fábrica en EventSource, la pieza que abre el flujo. Con el despliegue azul/verde, que levanta una copia nueva y apaga la vieja, el canal se corta varias veces al día. Con WebSocket habría que escribir esa reconexión a mano.
El canal no lleva contenido
Por el canal no viaja el mensaje nuevo. Viaja un aviso vacío: «algo cambió». El navegador entonces pide la bandeja por la ruta de siempre. Esa ruta ya sabe quién puede ver qué, con sus filtros por rol y por bandeja.
Mandar el contenido por el canal obligaría a repetir allí todas las reglas de permisos. Ese es el tipo de duplicado que acaba enseñándole a alguien una conversación que no era suya. Un canal que solo dice «vuelve a mirar» no puede filtrar mal: no lleva nada.
El tiempo real es un timbre, no un cartero. Suena, y quien abre la puerta sigue siendo el de siempre.
nginx se lo traga sin avisar
El fallo más traicionero del proyecto. La ruta del canal respondía 200, el navegador se quedaba esperando y ningún mensaje llegaba. Sin error en ningún sitio.
La causa estaba en nginx, el programa que recibe las visitas y se las pasa a la aplicación. De fábrica le habla en HTTP/1.0 y acumula la respuesta entera antes de entregarla. Para una página normal da igual. Para un flujo que no termina nunca, «antes de entregarla» es nunca.
| Directiva | Para qué |
|---|---|
proxy_http_version 1.1 y proxy_set_header Connection "" | Que nginx hable HTTP/1.1 con la app y mantenga la conexión |
proxy_buffering off | Que entregue cada evento al llegar, no al cerrar |
proxy_read_timeout 1h | Una bandeja tranquila puede estar horas sin novedades; cortar cada minuto es reconectar en bucle |
location = /ruta/exacta | Coincidencia exacta, la de mayor prioridad, para que gane al bloque general de /api/ sin depender del orden |
Desde la aplicación mandamos además la cabeceraX-Accel-Buffering: no, que le pide a nginx lo mismo por si la configuración cambia. Cinturón y tirantes: dos cosas que sujetan lo mismo.
El primer byte y el latido
Dos detalles del propio flujo. El primero, el arranque: lo primero que se manda es un comentario vacío, un byte que no dice nada. Hasta que no llegan bytes, la petición puede quedarse retenida en un intermediario sin que el navegador dé la conexión por establecida. Ese byte inútil la establece.
El segundo, el latido: cada 25 segundos se manda otro comentario vacío. Los intermediarios cortan las conexiones que llevan rato calladas, normalmente al minuto, y el latido va por debajo de ese plazo. Sin él, un negocio tranquilo perdería el canal cada minuto y lo volvería a abrir. Funciona, pero es reconectar en bucle para nada.
Cuando el navegador cierra la pestaña, el servidor se da de baja del aviso. Si no, seguiría avisando a un canal que ya no lee nadie.
Qué hacer entonces
- Usar SSE si el tráfico va en una sola dirección. La reconexión de fábrica vale más que una ida y vuelta sin usar.
- Emitir el aviso desde un disparador de la base: así avisan todas las puertas.
- No meter contenido en el canal: un timbre, y la ruta normal sirve los datos con sus permisos.
- Poner las cuatro directivas de nginx y la cabecera desde la app.
- Mandar un byte al abrir y un latido por debajo del minuto.
- Dejar el sondeo lento como red de seguridad.
Esta nota sale de nuestro trabajo en Software a medida.