🎯 Meta: que el servidor pueda empujar datos al navegador (notificaciones, chat, dashboards en vivo, progreso de tareas) y saber elegir la herramienta: SSE, WebSockets o polling. El apéndice D vio el lado servidor; aquí cierras el circuito con el frontend (React, Vue y HTMX) y los problemas reales: reconexión, escalado y proxies.
Requisitos: apéndice D (WebSockets/SSE backend), caps. 15-19, apéndice B (Redis pub/sub).
21.1 · Elegir la herramienta (el 80% del acierto)
HTTP normal es "pregunta-respuesta": el servidor no puede hablar primero. Tres salidas:
| Técnica | Dirección | Complejidad | Úsala para |
|---|---|---|---|
| Polling (fetch cada N seg) | cliente→servidor | Trivial | Datos que cambian lento (stock, badge cada 30 s) |
| SSE (Server-Sent Events) | servidor→cliente | Baja | Notificaciones, feeds, progreso, streaming de IA |
| WebSocket | ↔ bidireccional | Media-alta | Chat, juegos, colaboración, cursores en vivo |
¿El cliente también envía mensajes frecuentes por el mismo canal?
│
├─ NO ──▶ ¿Necesitas latencia < segundos?
│ ├─ NO ──▶ POLLING (no te compliques)
│ └─ SÍ ──▶ SSE ← empieza aquí casi siempre
└─ SÍ ──▶ WEBSOCKET🧠 SSE está infravalorado: es HTTP normal (pasa por cualquier proxy, se autentica con tu cookie de sesión del cap. 20, reconecta solo) y cubre notificaciones, dashboards y todo el streaming de LLMs (apéndice O). WebSocket es más potente pero pagas reconexión manual, autenticación aparte y escalado más difícil. Empieza por SSE; sube a WS cuando la bidireccionalidad lo exija.
21.2 · SSE completo: notificaciones en vivo
Backend (NestJS)
// notificaciones.controller.ts
import { Controller, Sse, UseGuards } from '@nestjs/common';
import { Observable, filter, map } from 'rxjs';
@Controller('notificaciones')
export class NotificacionesController {
constructor(private eventos: EventosService) {} // wrapper de un Subject/EventEmitter
@Sse('stream')
@UseGuards(SessionGuard) // ¡la cookie de sesión viaja sola! (cap. 20)
stream(@CurrentUser() user: User): Observable<MessageEvent> {
return this.eventos.flujo$.pipe(
filter((e) => e.userId === user.id), // cada uno recibe SOLO lo suyo
map((e) => ({ data: e, type: e.tipo, id: String(e.seq) })),
);
}
}
// Desde cualquier service: this.eventos.emitir({ userId, tipo: 'pedido', seq, ... })El wire format que viaja (texto plano sobre HTTP, con Content-Type: text/event-stream):
id: 42
event: pedido
data: {"mensaje":"Tu pedido #17 se ha enviado"}
id: 43
event: stock
data: {"producto":9,"quedan":2}En Flask/FastAPI es un generador que hace yield f"data: {json}\n\n"; en Go, un http.Flusher en un bucle. Mismo formato, cualquier stack del libro.
Frontend (React) — con EventSource
// hooks/useNotificaciones.ts
export function useNotificaciones() {
const [items, setItems] = useState<Notif[]>([]);
const [conectado, setConectado] = useState(false);
useEffect(() => {
// withCredentials: envía la cookie de sesión → el guard del backend te identifica
const es = new EventSource('/api/notificaciones/stream', { withCredentials: true });
es.onopen = () => setConectado(true);
es.onerror = () => setConectado(false); // EventSource RECONECTA SOLO; no hagas nada
es.addEventListener('pedido', (e) => {
setItems((prev) => [JSON.parse(e.data), ...prev].slice(0, 50));
});
return () => es.close(); // cleanup obligatorio (cap. 17.6)
}, []);
return { items, conectado };
}💡 Reconexión y
Last-Event-ID: si la conexión cae, el navegador reintenta y manda la cabeceraLast-Event-IDcon el últimoid:recibido. Si tu backend guarda un pequeño buffer (Redis Stream, ap. B), puede reenviar lo perdido: tiempo real sin huecos, gratis. Con WebSocket todo eso lo programas tú.
La misma feature con HTMX (extensión oficial sse)
<div hx-ext="sse" sse-connect="/api/notificaciones/stream">
<ul id="notifs" sse-swap="pedido" hx-swap="afterbegin"></ul>
<!-- el servidor manda los eventos con HTML directamente en data: -->
</div>21.3 · WebSockets completo: un chat con salas
Backend (NestJS + Socket.IO)
// chat.gateway.ts
@WebSocketGateway({ cors: { origin: process.env.FRONT_URL, credentials: true } })
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer() server: Server;
constructor(private auth: AuthService) {}
async handleConnection(socket: Socket) {
try {
// ⚠️ Autenticar EN EL HANDSHAKE: un WS abierto sin auth es una puerta abierta
const user = await this.auth.validarDesdeCookie(socket.handshake.headers.cookie);
socket.data.user = user;
} catch {
socket.disconnect(true);
}
}
@SubscribeMessage('unirse')
unirse(@ConnectedSocket() s: Socket, @MessageBody() sala: string) {
if (!puedeEntrar(s.data.user, sala)) throw new WsException('Prohibido'); // autoriza TAMBIÉN aquí
s.join(sala);
}
@SubscribeMessage('mensaje')
async mensaje(@ConnectedSocket() s: Socket, @MessageBody() dto: MensajeDto) {
const limpio = await this.chat.guardar(s.data.user, dto); // valida + persiste
this.server.to(dto.sala).emit('mensaje', limpio); // broadcast a la sala
}
}Frontend (React + socket.io-client)
// hooks/useChat.ts
export function useChat(sala: string) {
const [mensajes, setMensajes] = useState<Mensaje[]>([]);
const [estado, setEstado] = useState<'conectando' | 'online' | 'offline'>('conectando');
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io('/', { withCredentials: true }); // cookie de sesión en el handshake
socketRef.current = socket;
socket.on('connect', () => { setEstado('online'); socket.emit('unirse', sala); });
socket.on('disconnect', () => setEstado('offline'));
// socket.io reconecta con backoff solo; al reconectar RE-ENTRA a la sala:
socket.io.on('reconnect', () => socket.emit('unirse', sala));
socket.on('mensaje', (m: Mensaje) => setMensajes((prev) => [...prev, m]));
return () => { socket.disconnect(); };
}, [sala]);
const enviar = useCallback((texto: string) => {
socketRef.current?.emit('mensaje', { sala, texto });
}, [sala]);
return { mensajes, estado, enviar };
}🧠 ¿Socket.IO o WebSocket nativo? Socket.IO añade reconexión automática, salas, acks y fallback — todo lo que acabarías escribiendo. Nativo (
new WebSocket(url)) si controlas ambos extremos y quieres cero dependencias (y entonces implementa TÚ: ping/pong cada 30 s, reconexión con backoff exponencial + jitter, y re-suscripción al reconectar).
UI optimista + confirmación (el patrón de chat serio)
function enviarOptimista(texto: string) {
const temporal: Mensaje = { id: crypto.randomUUID(), texto, autor: yo, estado: 'enviando' };
setMensajes((prev) => [...prev, temporal]);
socket.emit('mensaje', { sala, texto, tempId: temporal.id }, (ack: Mensaje) => {
// el ack del servidor reemplaza el temporal (id real, timestamp real)
setMensajes((prev) => prev.map((m) => (m.id === temporal.id ? ack : m)));
});
}Mismo principio que useOptimistic (cap. 17): pinta ya, confirma o revierte después.
21.4 · Los problemas que solo aparecen en producción
1. Nginx corta tus conexiones (cap. 13 se une a la fiesta)
location /socket.io/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # imprescindible para WS
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # el default de 60 s MATA conexiones largas
}
location /api/notificaciones/stream { # SSE
proxy_pass http://backend;
proxy_buffering off; # sin esto, Nginx retiene los eventos ⚠️
proxy_read_timeout 3600s;
}2. Escalar a N instancias: el adapter de Redis
Con 2+ réplicas (cap. 12/G), un usuario conectado a la instancia A no recibe lo que emite la instancia B. Solución: pub/sub compartido (apéndice B):
// Socket.IO + Redis: los emit() se propagan a TODAS las instancias
import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));Para SSE casero: cada instancia se suscribe a un canal Redis y reemite a sus conexiones.
instancia A ──┐ ┌── usuario 1 (conectado a A)
instancia B ──┼──▶ Redis pub/sub ──▶┼── usuario 2 (conectado a B)
instancia C ──┘ └── usuario 3 (conectado a C)⚠️ Sticky sessions: con Socket.IO y varios nodos detrás de un balanceador necesitas además afinidad (
ip_hashen Nginx) o forzar transporte websocket puro, porque su handshake hace varias peticiones HTTP que deben caer en la misma instancia.
3. Heartbeat y conexiones zombis
Redes móviles y proxies matan conexiones en silencio. WebSocket necesita ping/pong (Socket.IO lo trae); SSE puede mandar un comentario : ping\n\n cada 25 s. Sin heartbeat, tu servidor acumula miles de conexiones muertas y el cliente cree estar online.
4. No abuses del canal
El canal en vivo transporta eventos, no es tu API. Patrón robusto: el evento notifica ("hay pedidos nuevos"), y el cliente refresca por HTTP normal:
es.addEventListener('pedidos-cambiados', () => {
queryClient.invalidateQueries({ queryKey: ['pedidos'] }); // TanStack Query refetchea
});Así el estado sigue teniendo UNA fuente de verdad (tu API REST) y perderte un evento no corrompe nada.
21.5 · Tests de tiempo real
// Backend e2e (NestJS + socket.io-client en el test)
it('reparte el mensaje a los miembros de la sala', async () => {
const [ana, luis] = await Promise.all([conectar(tokenAna), conectar(tokenLuis)]);
ana.emit('unirse', 'general');
luis.emit('unirse', 'general');
const recibido = new Promise((res) => luis.on('mensaje', res));
ana.emit('mensaje', { sala: 'general', texto: 'hola' });
await expect(recibido).resolves.toMatchObject({ texto: 'hola', autor: 'Ana' });
});
it('rechaza conexión sin sesión', async () => {
const socket = io(URL, { autoConnect: true }); // sin cookie
await new Promise((res) => socket.on('disconnect', res));
});En el frontend, mockea el socket (inyectándolo por props/context) y testea el reducer de mensajes; el flujo completo cae en Playwright con dos páginas (browser.newContext() × 2 = dos usuarios chateando de verdad).
21.6 · Buenas prácticas de tiempo real
- Polling → SSE → WebSocket, en ese orden de escalada. No empieces por el final.
- Autentica el handshake y autoriza cada sala/acción — un WS abierto es un endpoint más.
- Valida cada mensaje entrante como si fuera un body HTTP (lo es).
- Diseña la reconexión desde el día 1: estado visible al usuario ("reconectando…"), re-suscripción, y recuperación de eventos perdidos (
Last-Event-ID/ historial al entrar). - Eventos notifican, la API es la verdad. Invalida y refetchea; no dupliques estado.
- Nginx:
Upgrade+ timeouts largos +proxy_buffering offpara SSE. - Redis adapter desde la segunda instancia. Y métricas de conexiones activas (ap. F).
- Rate limit también aquí: un cliente puede emitir 10.000 mensajes/segundo si le dejas.
✅ Ejercicio del capítulo
Añade tiempo real a la tienda con auth del cap. 20:
1. SSE /notificaciones/stream autenticado por cookie: cuando un admin cambia el
estado de un pedido, su dueño ve la notificación al instante (filtrado por userId).
2. Campana con badge en el frontend (React o Vue) usando tu hook useNotificaciones,
con indicador de conexión y recuperación vía Last-Event-ID (buffer en Redis Stream).
3. Chat de soporte cliente↔admin con Socket.IO: salas por usuario, auth en handshake,
autorización de sala, persistencia en PostgreSQL y envío optimista con ack.
4. Al recibir 'pedidos-cambiados', invalida la query de pedidos (patrón notificar+refetch).
5. Configura Nginx para ambos endpoints y pruébalo A TRAVÉS del proxy.
6. Levanta 2 instancias del backend (compose scale) + Redis adapter y verifica que un
mensaje emitido en una llega a un cliente conectado a la otra.
7. Tests: e2e del gateway (sala + rechazo sin auth) y Playwright con 2 contextos chateando.💡 Pistas de la solución
- El buffer de SSE:
XADD notifs-{userId} MAXLEN 100 * data {json}; al conectar conLast-Event-ID,XRANGEdesde ese id y reemite antes de pasar al flujo en vivo. - El fallo típico del punto 5: olvidar
proxy_buffering off— los eventos "llegan todos de golpe" al cerrar. Si ves eso, es Nginx bufferizando. - Para el punto 6:
docker compose up --scale api=2+ Nginxupstreamcon las dos réplicas; demuestra el fallo SIN adapter primero (mensaje que no llega) y luego arréglalo.
🧠 Autoevaluación
Cuando el flujo es (casi) solo servidor→cliente: notificaciones, feeds, progreso, streaming de IA. SSE es HTTP normal: reconecta solo, atraviesa proxies sin drama y usa tu auth de cookies. WebSocket solo cuando el cliente también emite con frecuencia por el mismo canal (chat, colaboración).
Cada instancia solo conoce SUS conexiones. Sin bus compartido, un emit en la instancia B no llega a usuarios conectados a la A. El adapter propaga los eventos por Redis pub/sub a todas las instancias.
Al reconectar, el navegador manda el último id: recibido. Si el backend mantiene un buffer de eventos (p. ej. Redis Stream), puede reenviar los perdidos durante la desconexión — tiempo real sin huecos.
Porque los eventos se pueden perder o llegar desordenados. Si el evento solo dispara un refetch a la API REST, el estado siempre converge a la fuente de verdad; perder un evento solo retrasa la actualización, nunca corrompe datos.
El proxy: Nginx bufferiza la respuesta por defecto. proxy_buffering off (y X-Accel-Buffering: no desde la app) en la location del stream.
Siguiente: 22-proyecto-final.md — todo el libro en una sola app.