Diseño de backends para chats en tiempo real: salas, persistencia y escalabilidad
Aprenda cómo diseñar un backend de chat en tiempo real utilizando Socket.IO, PostgreSQL y Redis, abordando temas como las salas, el orden de persistencia de los mensajes, la presencia y la escalabilidad en múltiples servidores.
La comunicación en tiempo real cambia la forma en que un servidor y un cliente se relacionan entre sí, pasando de una simple interacción solicitud-respuesta a un entorno basado en WebSockets, salas, persistencia de mensajes, seguimiento de presencia y escalabilidad mediante Redis.
La mayoría de las APIs siguen un patrón predecible:
Client
↓
HTTP Request
↓
Server
↓
HTTP Response
El cliente realiza una solicitud. El servidor envía una respuesta. Esa es toda la interacción.
Pero ahora piense en lo que se necesitaría para crear algo como:
- Slack
- Discord
- Canales de notificaciones en vivo
- Indicadores de presencia en línea
- Indicadores de que alguien está escribiendo
- Tableros de control en tiempo real
- Funcionalidad multijugador
En estos casos, no se desea que el cliente verifique repetidamente si ha habido cambios con:
"¿Ha cambiado algo?"
En lugar de eso, se quiere que el propio servidor pueda anunciar los cambios:
"Algo ha cambiado. He aquí la actualización."
Este es exactamente el problema que resuelve la comunicación en tiempo real.
1. HTTP vs Comunicación en Tiempo Real
El polling tradicional con HTTP se ve así:
Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."
Funciona, pero desperdicia solicitudes y ancho de banda al verificar actualizaciones que normalmente no existen.
Una conexión en tiempo real persistente se comporta de manera diferente:
Client ←────────────→ Server
connection
Server → New message
Server → User online
Server → Typing...
Server → Message read
Una vez que la conexión está abierta, el servidor puede enviar eventos directamente al cliente cada vez que ocurre algo.
WebSockets son una tecnología ampliamente utilizada para permitir este tipo de conexión. En el ecosistema Node.js, Socket.IO es una biblioteca popular creada específicamente para la comunicación en tiempo real.
2. Una Arquitectura Simple
Una aplicación de chat mínima podría estructurarse de esta manera:
┌──────────────┐
│ Web / Mobile │
└──────┬───────┘
│
WebSocket
│
▼
┌──────────────┐
│ Node.js │
│ Socket.IO │
└──────┬───────┘
│
┌──────────┴──────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ PostgreSQL│ │ Redis │
│ Messages │ │ Pub/Sub │
└───────────┘ └───────────┘
Cada componente desempeña un papel distinto.
Node.js + Socket.IO
Gestiona las conexiones en tiempo real y los eventos que fluyen a través de ellas.
PostgreSQL
Almacena los mensajes y el historial de conversaciones.
Redis
Se utiliza cuando se ejecutan más de una instancia de la aplicación y es necesario que estas permanezcan sincronizadas para los eventos en tiempo real.
3. Configuración de Socket.IO
Una configuración básica del servidor podría verse así:
import { Server } from "socket.io";
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true,
},
});
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
Cada vez que un cliente se conecta, Socket.IO establece un socket dedicado para esa conexión, de modo que cada cliente conectado recibe su propia instancia de socket.
4. Los eventos son el concepto central
En lugar de un enfoque basado en solicitudes:
"Call this endpoint"
los sistemas en tiempo real suelen organizarse en torno a eventos nombrados:
"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"
Por ejemplo, el servidor podría emitir:
socket.emit("message-created", {
id: message.id,
text: message.text,
});
Y el cliente escucha ese mismo evento:
socket.on("message-created", (message) => {
console.log("New message:", message);
});
Este cambio hacia un modelo basado en eventos es una de las formas fundamentales en que las aplicaciones en tiempo real difieren de las API convencionales basadas en REST.
5. Las salas facilitan enormemente los sistemas de chat
Imagínese una conversación uno a uno:
User A
User B
No se desea difundir cada mensaje a todos los usuarios conectados, sino solo a aquellos que realmente participan en esa conversación.
Socket.IO permite agrupar sockets en una sala:
socket.join(`conversation:${conversationId}`);
Luego, cada vez que se crea un nuevo mensaje, se emite en esa sala específica:
io.to(`conversation:${conversationId}`)
.emit("message-created", message);
Solo los sockets que se unieron a esa sala recibirán el evento.
La misma idea se aplica para agrupar conversaciones. Una sala como:
conversation:123
puede contener varios participantes:
User A
User B
User C
User D
Y un único mensaje emitido llega a todos en esa sala al mismo tiempo.
6. No guarde el mensaje después de difundirlo
Esta es una decisión de diseño sobre la que vale la pena reflexionar detenidamente.
Una secuencia arriesgada sería:
Receive message
↓
Broadcast message
↓
Save to database
El problema: ¿qué sucede si la escritura en la base de datos falla después de que el mensaje ya se ha enviado? Los usuarios habrían visto un mensaje que en realidad nunca se guardó, lo que genera una inconsistencia entre lo que ven y lo que está almacenado.
Un patrón más fiable es persistir primero y luego difundir:
Client
↓
send-message
↓
Validate
↓
Save to PostgreSQL
↓
Database succeeds
↓
Broadcast event
En la práctica, esto se vería más o menos así:
socket.on("send-message", async (data) => {
const message = await saveMessage(data);
io.to(`conversation:${data.conversationId}`)
.emit("message-created", message);
});
Las garantías de durabilidad exactas que necesite variarán según la aplicación, pero el principio subyacente es generalmente el mismo: la forma en que se persisten y entregan los mensajes debe ser una decisión intencionada, no algo pensado después.
7. Almacenar el historial de chats en PostgreSQL
Crear un sistema basado en eventos en tiempo real no implica que todo deba existir únicamente en memoria.
La gente espera que, al abrir una conversación al día siguiente, sus mensajes anteriores sigan estando allí.
Un esquema simplificado podría verse así:
CREATE TABLE messages (
id UUID PRIMARY KEY,
conversation_id UUID NOT NULL,
sender_id UUID NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Luego, cada vez que un usuario abra una conversación, se ejecutaría algo similar a:
SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;
En este punto hemos dividido el trabajo en dos responsabilidades distintas:
Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history
Mantener estas tareas separadas es muy importante.
8. Agregar un índice para el historial de conversaciones
Si su aplicación ejecuta regularmente una consulta con este formato:
WHERE conversation_id = ?
ORDER BY created_at DESC
entonces su esquema de base de datos debe diseñarse teniendo en cuenta ese patrón.
Por ejemplo:
CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);
El objetivo no es distribuir índices por todas partes sin pensar.
Un índice solo es útil si coincide con las consultas que realmente ejecuta tu aplicación.
Y, repitiendo algo ya mencionado anteriormente:
Siempre mide el rendimiento antes y después de realizar el cambio.
9. La presencia en línea es diferente del almacenamiento de mensajes
Imaginemos que quieres mostrar algo como:
Mit
● Online
No hay necesidad de persistir algo como:
user.is_online = true
dentro de PostgreSQL cada vez que un usuario se conecta.
¿Por qué no?
Porque el estado de presencia cambia constantemente.
Para la mayoría de los sistemas, es más adecuado almacenar este tipo de datos de presencia de corta duración en Redis.
Por ejemplo:
online:user:123
TTL → 60 seconds
El cliente puede enviar señales periódicas para indicar que sigue activo.
Una vez que dejan de llegar esos latidos cardíacos, la clave de presencia simplemente expira por sí sola.
Esto evita que el estado de conexión temporal sea confundido con datos permanentes aptos para la base de datos.
10. Los indicadores de escritura son aún más temporales
Tomemos algo como esto:
Mit is typing...
¿Necesita esto una fila en PostgreSQL?
Definitivamente no.
Es puramente transitorio.
Basta con un evento de socket:
socket.to(roomId).emit("user-typing", {
userId,
});
Y una vez que el usuario deja de escribir:
socket.to(roomId).emit("user-stopped-typing", {
userId,
});
Esto apunta a un principio de diseño más amplio:
No todo lo que rastrea su aplicación necesita estar en una base de datos.
Una buena prueba es determinar si la información necesita sobrevivir a un reinicio del servidor.
Si no es así, probablemente sea más adecuado utilizar algún tipo de almacenamiento efímero.
11. El problema con múltiples servidores Node.js
Aquí es donde las cosas comienzan a volverse más complejas.
Imagínese una configuración con solo un servidor Node.js:
Client
↓
Node.js
A esa escala, todo funciona sin problemas.
Pero luego aumenta el tráfico.
Ahora la configuración se parece más a esto:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
El usuario A termina conectado a Node.js A.
El usuario B termina conectado a Node.js B.
Ahora el usuario A envía un mensaje.
¿Cómo debe saber Node.js B que necesita entregar ese evento al usuario B?
Este es exactamente el tipo de problema que requiere una capa de mensajería compartida entre las instancias del servidor.
12. Redis puede conectar múltiples instancias
Una solución común es Redis, combinado con el adaptador Socket.IO para Redis.
Conceptualmente, se ve así:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
\ /
\ /
Redis
Con esto en su lugar, un evento creado en una instancia del servidor puede propagarse a las demás.
Eso es lo que permite que la capa en tiempo real supere los límites de un único proceso Node.js.
Es importante aclarar que Redis no es un reemplazo para PostgreSQL en este caso.
Las dos herramientas sirven propósitos diferentes.
PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination
13. La autenticación sigue siendo importante
Establecer una conexión WebSocket no implica automáticamente que esa conexión sea fiable.
Sigue siendo necesaria la autenticación.
Un enfoque típico es que el cliente se conecte presentando algún tipo de token.
Antes de permitir el acceso a conversaciones privadas, el servidor debe validar ese token.
Conceptualmente, el flujo es el siguiente:
Client
↓
Connection
↓
Authenticate
↓
Validate user
↓
Allow socket connection
Luego, cuando un cliente intenta unirse a una sala como:
conversation:123
el servidor necesita confirmar que el usuario autenticado es realmente un participante en esa conversación.
Nunca acepte simplemente:
socket.join(conversationId);
suponiendo que el ID proporcionado por el cliente pueda considerarse fiable sin más.
14. Gestionar desconexiones
La interrupción inesperada de las conexiones es una realidad constante en los sistemas en tiempo real.
Un usuario podría:
- Cerrar su navegador
- Pierder la conexión Wi-Fi
- Cambiar de red
- Hacer que su teléfono entre en modo de ahorro de energía
- Pierder la señal móvil
- Cerrar la aplicación forzadamente
Por este motivo, su servidor necesita una lógica como:
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason);
});
También es necesario tener en cuenta la lógica de reconexión.
Una interrupción temporal de cinco segundos en la red no debería significar que un usuario pierda permanentemente el acceso a las funciones en tiempo real.
Esto es parte de la razón por la cual los sistemas en tiempo real suelen requerir una gestión del estado más cuidadosa que una API REST típica.
15. Los sistemas en tiempo real no necesitan que todo funcione mediante WebSockets
Aquí hay otro punto clave a tener en cuenta.
No existe ninguna regla que exija reconstruir toda la aplicación en torno a conexiones WebSocket.
Puede combinar diferentes enfoques:
REST API
+
WebSockets
+
PostgreSQL
+
Redis
Por ejemplo:
REST
Utilice REST al manejar:
Login
Get conversation history
Create conversation
Upload files
Search messages
WebSocket
Utilice eventos WebSocket al manejar:
New message
Typing indicator
Online status
Read receipts
Live notifications
Esta configuración híbrida tiende a simplificar las cosas mucho más que intentar gestionarlo todo a través de sockets.
16. Aspectos a considerar para producción
Pasar a entornos en tiempo real añade una nueva capa de preocupaciones.
Tendrá que tener en cuenta:
Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance
Y si está desarrollando algo más parecido a un producto de mensajería completo, añada además:
Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination
El alcance del problema crece rápidamente.
Esa es precisamente la razón por la cual la primera versión no debería intentar resolverlo todo de una sola vez.
17. Un punto de partida razonable
Para una aplicación más pequeña, una arquitectura inicial sensata sería la siguiente:
Client
│
▼
┌───────────────┐
│ Node.js │
│ REST + WS │
└───────┬───────┘
│
┌──────┴──────┐
▼ ▼
PostgreSQL Redis
Messages Cache /
Users Presence
Puede ampliarse a partir de ahí una vez que sea realmente necesario:
Load Balancer
/ \
▼ ▼
Node.js A Node.js B
\ /
\ /
Redis
│
▼
PostgreSQL
Mantenga la construcción inicial simple. Mida su rendimiento en condiciones reales de uso. Solo entonces escale los componentes que resulten ser verdaderos cuellos de botella.
18. Una lista de verificación para servidores en tiempo real
Antes de considerar que un servidor de chat está listo para producción, confirme:
[ ] Authentication implemented
[ ] Authorization for conversations
[ ] WebSocket connection handling
[ ] Room management
[ ] Message persistence
[ ] Message pagination
[ ] Reconnection handling
[ ] Duplicate message handling
[ ] Online/offline presence
[ ] Typing indicators
[ ] Rate limiting
[ ] Redis for multi-instance coordination
[ ] Logging
[ ] Monitoring
[ ] Database indexes
[ ] Load testing
[ ] Failure scenarios tested
Reflexión final
Desde el exterior, desarrollar una función de chat parece sencillo.
Escribe usted:
"Hello"
Y otra persona lo recibe:
"Hello"
Pero detrás de esas dos palabras se esconde todo un conjunto de desafíos técnicos:
Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling
Esa complejidad oculta es precisamente lo que hace que los sistemas en tiempo real valgan la pena entender bien.
La lección principal que vale la pena recordar es esta:
Resista la tentación de crear un sistema masivamente distribuido desde el primer día.
Comience con:
Node.js
+
Socket.IO
+
PostgreSQL
Observe cómo se comporta esta combinación en condiciones reales.
Introduzca Redis y otras instancias solo cuando los requisitos reales lo hagan necesario.
Mantenga la primera versión mínima. Tómese el tiempo para comprender cómo funcionan juntas las piezas esenciales. Observe cómo responde el sistema bajo carga real. Solo amplíe aquellas partes de la configuración que realmente necesiten más capacidad.
Esa progresión es la forma en que una función básica de chat se convierte en un backend en tiempo real fiable.
Lecturas relacionadas
- Fundamentos del caché de Redis: Patrones, errores y conceptos en entrevistas — Aprenda cómo funciona el caché de Redis en aplicaciones Node.js, desde los métodos cache-aside y TTL hasta la protección contra sobrecargas, las políticas de eliminación y las preguntas comunes en entrevistas.
- Encontrar el verdadero cuello de botella en un endpoint lento de Node.js — Aprenda un método sistemático para rastrear la latencia del backend a lo largo de la ruta de la solicitud, desde el código Node.js hasta las consultas a la base de datos, utilizando medidores de tiempo y EXPLAIN ANALYZE.