Qué añade Socket.IO a los WebSockets
Socket.IO es una librería para la comunicación en tiempo real basada en eventos entre un servidor y sus clientes. Internamente, utiliza el protocolo WebSocket cuando es posible y recurre a HTTP long polling cuando no puede hacerlo. Sobre ese transporte, te proporciona un pequeño protocolo de aplicación: eventos nombrados, rooms, acknowledgements, reconexión automática y heartbeats.
Esa capa adicional es precisamente el objetivo. Una conexión WebSocket pura te ofrece un canal y nada más. Cualquier aplicación real tiene que volver a construir lo mismo: ¿cómo agrupo las conexiones por sala de chat o tenant?, ¿cómo sé que un mensaje fue recibido?, ¿cómo reconecto de forma limpia?, ¿cómo hago un broadcast a través de varios servidores? Socket.IO responde a esas preguntas una sola vez, en una librería bien probada, para que puedas dedicar tu tiempo al producto.
La contrapartida es que Socket.IO no es un WebSocket puro. Define su propio handshake y formato de paquetes sobre Engine.IO, por lo que un cliente WebSocket nativo no puede conectarse a un servidor de Socket.IO. Ambas partes deben utilizar el cliente de Socket.IO. Si necesitas interoperabilidad con clientes WebSocket arbitrarios, utiliza la guía de WebSockets y la librería ws en su lugar.
El resto de esta guía asume que ya conoces el protocolo puro y que ahora buscas la versión “batteries-included”.
Configuración del servidor y del cliente
El servidor se acopla a un servidor HTTP existente, que generalmente es el mismo que sirve tu API. Esto significa un solo puerto, un único certificado TLS y sin infraestructura adicional.
import { Server } from "socket.io";
import { createServer } from "node:http";
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.APP_ORIGIN, credentials: true },
});
io.on("connection", (socket) => {
console.log("connected", socket.id);
});
httpServer.listen(3000);
El cliente se conecta con el mismo origen y se autentica a través del handshake.
import { io } from "socket.io-client";
const socket = io("https://api.example.com", {
auth: { token: getAccessToken() },
withCredentials: true,
});
socket.on("connect", () => console.log("connected", socket.id));
socket.on("disconnect", (reason) => console.log("closed", reason));
El socket.id es un identificador por conexión. Es útil para el logging y para dirigirse a una conexión específica, pero cambia al reconectar, por lo que nunca debe usarse como un id de usuario ni almacenarse como un estado persistente.
Eventos, acknowledgements y callbacks
Todo en Socket.IO es un evento nombrado que transporta un payload JSON. Tanto el servidor como el cliente utilizan emit para enviar y on para escuchar. Los nombres son simplemente strings, así que elige una convención y mantenla; noun:verb como message:send, message:new y presence:joined se lee bien en ambos lados.
La característica que distingue a Socket.IO de un socket básico es el acknowledgement. Si el emisor pasa un callback como último argumento, el receptor puede llamarlo para responder, convirtiendo el emit en una solicitud/respuesta sobre la misma conexión.
// client
socket.emit("message:send", { room: "general", body: "hello" }, (ack) => {
if (!ack.ok) showError(ack.error);
});
// server
socket.on("message:send", (payload, ack) => {
if (!payload.body) return ack({ ok: false, error: "empty" });
io.to(payload.room).emit("message:new", payload);
ack({ ok: true, at: Date.now() });
});
Utiliza acknowledgements para cualquier evento cuyo resultado el cliente necesite conocer: crear un registro, unirse a una sala o enviar un formulario. Para broadcasts puros donde nadie espera una respuesta, un emit simple es suficiente. Un punto medio es socket.timeout(5000).emit(...), que falla el callback si no llega un acknowledgement a tiempo.
Middleware y el pipeline de eventos
Socket.IO tiene dos capas de middleware, y colocar la lógica en la capa correcta permite mantener los handlers limpios.
El middleware de conexión, registrado con io.use o namespace.use, se ejecuta una vez por conexión antes del evento connection. Aquí es donde deben ir la autenticación, la resolución de tenants y la configuración por conexión. El middleware se ejecuta en el orden de registro, y cada uno llama a next() para continuar o a next(new Error(...)) para rechazar la conexión.
io.use((socket, next) => {
const startedAt = Date.now();
socket.on("disconnect", () => {
metrics.observe("socket.duration", Date.now() - startedAt);
});
next();
});
El middleware de eventos, registrado con socket.use, se ejecuta para cada evento entrante en ese socket. Es el lugar natural para la validación, el rate limiting y el logging estructurado, ya que puede ver el nombre del evento y el payload antes que cualquier handler.
const chat = io.of("/chat");
chat.use((socket, next) => {
if (!socket.data.user) return next(new Error("unauthorized"));
next();
});
chat.use((socket, next) => {
socket.onAny((event, ...args) => {
logger.info({ event, userId: socket.data.user.id, args });
});
next();
});
socket.onAny observa cada evento entrante, y socket.onAnyOutgoing observa todo lo que envías; juntos, te proporcionan un trazo completo de una conexión sin tocar un solo handler. El middleware también reconoce los namespaces: el namespace /admin puede exigir un rol diferente sin afectar a /chat.
Rooms y namespaces
Existen dos mecanismos de agrupación para mantener los mensajes dirigidos en lugar de transmitirlos a todo el mundo.
Un room es una etiqueta del lado del servidor aplicada a un conjunto de sockets. Cualquier socket puede unirse o salir de cualquier room en cualquier momento, y un socket puede estar en varios rooms a la vez. Cuando emites un evento a un room, solo sus miembros lo reciben.
io.on("connection", (socket) => {
socket.join(`user:${socket.data.user.id}`);
socket.on("room:join", (room, ack) => {
socket.join(room);
ack({ ok: true });
});
});
Un namespace es un canal de comunicación independiente bajo una ruta, como /chat o /admin. Los namespaces tienen su propio middleware, sus propios manejadores de conexión y sus propios rooms. Utiliza los namespaces para separar responsabilidades que no deban compartir eventos en absoluto —por ejemplo, un namespace de chat público y un namespace de administración interna— en lugar de usarlos para modelar datos dentro de una misma funcionalidad.
Los rooms son la herramienta principal. Modélalos basándote en las cosas que interesan a tus usuarios: una conversación, un documento, un tenant, un dashboard. De este modo, el broadcasting se convierte en una sola línea de código en lugar de un bucle sobre las conexiones.
Difusión y segmentación (Broadcasting and targeting)
Socket.IO tiene un vocabulario reducido para definir quién recibe un evento; configurarlo correctamente evita tanto las fugas de datos como el desperdicio de recursos en el envío masivo (fan-out).
io.emit(...)— todos los sockets conectados. Rara vez es lo que buscas.socket.emit(...)— solo el socket que está manejando el evento actual.socket.broadcast.emit(...)— todos excepto el remitente.socket.to(room).emit(...)— todos en la sala excepto el remitente.io.to(room).emit(...)— todos en la sala, incluyendo al remitente.io.to(roomA).to(roomB).emit(...)— la unión de ambas salas.socket.to(socketId).emit(...)— un socket específico mediante su id.
socket.on("typing", ({ room }) => {
// Tell the room, but not the person typing.
socket.to(room).emit("typing", { user: socket.data.user.id });
});
Encadenar to une a los destinatarios; no existe un operador de intersección en la API principal. Si necesitas “miembros de la sala A que también sean administradores”, modélalo como una sala independiente — room:${id}:admins — en lugar de intentar calcularlo al momento de emitir el evento.
Autenticando el handshake
La autenticación debe formar parte del handshake de la conexión, no del primer mensaje. El middleware de Socket.IO registrado con io.use se ejecuta antes del evento connection, por lo que puedes rechazar un socket no autenticado antes de que pueda emitir cualquier cosa o unirse a alguna sala.
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const payload = verifyToken(token);
socket.data.user = { id: payload.sub, roles: payload.roles };
next();
} catch {
next(new Error("unauthorized"));
}
});
Hay dos detalles importantes. Primero, socket.data es el lugar adecuado para adjuntar el estado por conexión; este persiste durante toda la vida de la conexión y está disponible en cada handler. Segundo, una conexión rechazada envía un evento connect_error en el cliente, permitiendo que la UI solicite un token nuevo en lugar de reintentar indefinidamente.
Para clientes de navegador, una cookie enviada con el handshake es una alternativa al token explícito, lo que permite reutilizar la infraestructura de sesión existente. En cualquier caso, establece siempre el origin de cors hacia tu propia aplicación y autoriza cada evento basándote en el usuario en socket.data: la autenticación demuestra quién se conectó, no qué tiene permitido hacer.
Reconexión y recuperación del estado de la conexión
Las conexiones se caen. Las laptops entran en modo suspensión, los teléfonos cambian de red, los balanceadores de carga se reinician. Socket.IO se reconecta automáticamente utilizando exponential backoff y jitter, y emite los eventos reconnect_attempt y reconnect para que puedas mostrar la interfaz de usuario adecuada.
Sin embargo, por defecto, los eventos enviados mientras el cliente estuvo ausente se pierden. El cliente se reconecta como un nuevo socket y reanuda la actividad desde ese momento. Esto es aceptable para un feed en vivo, pero incorrecto para un chat o un documento colaborativo.
La recuperación del estado de la conexión (connection state recovery) resuelve el caso de desconexiones breves. Actívala en el servidor y cualquier cliente que se reconecte presentando su ID de sesión recibirá los paquetes que perdió, siempre y cuando la desconexión haya sido corta.
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
});
La recuperación está limitada deliberadamente: mantiene los paquetes recientes en memoria durante un intervalo corto y solo en la instancia que gestionó la conexión original, por lo que no es un sustituto del almacenamiento persistente. Cualquier dato que deba sobrevivir a una caída prolongada —mensajes, pedidos, ediciones de documentos— debe guardarse en una base de datos, utilizando el socket únicamente para notificar a los clientes que algo ha cambiado.
Escalado con el adaptador de Redis
Un servidor de Socket.IO mantiene sus sockets y salas en memoria. Si ejecutas dos instancias detrás de un balanceador de carga, tendrás efectivamente dos aplicaciones en tiempo real separadas: un mensaje emitido en la instancia A nunca llegará a los clientes de la instancia B.
El adaptador de Redis soluciona esto. Cada instancia publica sus broadcasts en un canal de pub/sub de Redis y se suscribe al mismo canal, de modo que un emit en una instancia se entrega a los sockets correspondientes en todas partes.
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(httpServer, {
adapter: createAdapter(pubClient, subClient),
});
Dos notas operativas. Primero, las sticky sessions siguen siendo necesarias durante la fase de HTTP polling, ya que el handshake de Engine.IO abarca varias solicitudes que deben llegar a la misma instancia. Configura el balanceador de carga para usar afinidad basada en cookies, o fuerza únicamente el transporte WebSocket. Segundo, el adaptador utiliza Redis pub/sub, que es rápido pero no duradero: un mensaje publicado mientras una instancia se está reiniciando se perderá. Redis también se trata en su propia guía de Redis.
Para despliegues muy grandes, existe un adaptador sharded que distribuye los canales a través de un clúster de Redis, así como adaptadores para otros brokers, pero comienza con el estándar.
Emitir desde fuera de un socket
Los eventos en tiempo real rara vez se originan dentro de un manejador de conexión. Una solicitud HTTP crea un comentario, un worker finaliza un reporte, un webhook confirma un pago; todos estos casos necesitan notificar a los clientes conectados.
Mantén una referencia a io y emite a una room desde cualquier lugar:
app.post("/comments", async (req, res) => {
const comment = await db.comment.create({ data: req.body });
io.to(`post:${comment.postId}`).emit("comment:new", comment);
res.status(201).json(comment);
});
Las rooms son la herramienta adecuada en este caso porque se comparten entre instancias cuando el adapter está configurado. Para llegar a un usuario específico, emite a una room por usuario — user:${id} — a la que el socket se haya unido al momento de la conexión, en lugar de rastrear los socket ids. Esto permite que siga funcionando cuando el usuario tiene dos pestañas abiertas, se reconecta o cae en una instancia diferente.
Si realmente necesitas un socket id, io.in(room).fetchSockets() devuelve los sockets activos en una room, lo cual es útil para conteos de presencia y envíos dirigidos.
Presencia, indicadores de escritura y funciones colaborativas
Las salas junto con un store compartido cubren la mayoría de las funciones colaborativas. La presencia —quién está conectado— es la más común, y la versión ingenua deja de funcionar en el momento en que ejecutas más de una instancia.
El patrón consiste en mantener la presencia autoritativa en Redis, no en un map de JavaScript, y realizar la limpieza al desconectarse:
io.on("connection", async (socket) => {
const { id } = socket.data.user;
await redis.sadd("online", id);
socket.on("disconnect", async () => {
const sockets = await io.in(`user:${id}`).fetchSockets();
if (sockets.length === 0) await redis.srem("online", id);
});
});
La comprobación de fetchSockets es importante: un usuario con dos pestañas abiertas no debería aparecer como desconectado cuando se cierra una de ellas. Los indicadores de escritura, las posiciones del cursor y las banderas de “alguien está editando” siguen la misma estructura: estado efímero transmitido a una sala, con una expiración corta para que un cliente que haya fallado no deje un indicador obsoleto para siempre.
Para una edición colaborativa real con resolución de conflictos, recurre a una librería de CRDT como Yjs y envía sus actualizaciones a través del socket, en lugar de intentar inventar un algoritmo de fusión.
Validación, rate limiting y seguridad
Un socket es un canal de entrada no confiable, exactamente igual que una solicitud HTTP. Trata cada payload de evento como hostil hasta que sea validado.
- Valida las estructuras. Analiza los payloads con una librería de esquemas como Zod antes de manipularlos, y recházalos con un acuse de recibo en lugar de lanzar una excepción.
- Aplica rate limiting. Un socket puede emitir miles de eventos por segundo. Utiliza un token bucket por socket y desconecta o limita a los usuarios que abusen del sistema.
- Limita el tamaño del payload.
maxHttpBufferSizedefine el límite de cuánta información puede transportar un solo mensaje. - Autoriza cada evento. Vuelve a verificar que el usuario en
socket.datatenga permiso para actuar sobre la sala o el recurso, y no te limites a comprobar que esté conectado. - Valida el origen. Configura
cors.originy no lo dejes abierto en producción. - Mantén los timeouts. Los heartbeats y los idle timeouts evitan que las conexiones abandonadas generen fugas de memoria.
io.on("connection", (socket) => {
socket.use(([event, payload], next) => {
const parsed = MessageSchema.safeParse(payload);
if (!parsed.success) return next(new Error("invalid_payload"));
if (!takeToken(socket.id)) return next(new Error("rate_limited"));
next();
});
});
El middleware por socket registrado con socket.use es el lugar ideal para centralizar estas comprobaciones, permitiendo que los handlers individuales se centren únicamente en la lógica de negocio.
Depuración y observabilidad
La primera herramienta ya viene integrada. Configurar DEBUG=socket.io:* (o engine*) imprime el handshake, las actualizaciones de transporte y el flujo de paquetes, lo cual suele ser suficiente para diagnosticar un cliente que no logra conectarse.
Para producción, monitorea las señales que realmente predicen incidentes:
- Sockets conectados — una caída repentina indica un despliegue, un crash o una partición de red.
- Eventos por segundo, por nombre — un pico en un evento suele ser el resultado de un bucle infinito en el cliente.
- Motivos de desconexión —
ping timeoutytransport closeapuntan a problemas de red o del proxy, mientras queio server disconnectsignifica que tu código cerró el socket. - Estado del adapter — si el pub/sub de Redis está lento o desconectado, las emisiones entre instancias se detienen sin que parezca que ningún socket ha fallado.
io.on("connection", (socket) => {
socket.on("disconnect", (reason) => {
metrics.increment("socket.disconnect", { reason });
});
});
El paquete @socket.io/admin-ui añade un dashboard sobre estos mismos datos, y vale la pena ejecutarlo en staging para poder ver las salas y los sockets a medida que cambian. Como ocurre con cualquier sistema en tiempo real, los fallos más confusos son los parciales: una instancia funciona correctamente, otra no, y solo una vista por instancia permite revelarlo.
Probando un servidor en tiempo real
El código en tiempo real es testeable. Inicia el servidor en un puerto efímero, conecta algunas instancias de socket.io-client y realiza aserciones sobre los eventos que reciben. La disciplina fundamental es esperar (await) los eventos en lugar de usar sleep, para que los tests sean rápidos y deterministas.
import { io as Client } from "socket.io-client";
test("broadcasts a message to the room", async () => {
const a = Client(url, { auth: { token } });
const b = Client(url, { auth: { token } });
await Promise.all([once(a, "connect"), once(b, "connect")]);
a.emit("room:join", "r1");
b.emit("room:join", "r1");
const received = once(b, "message:new");
a.emit("message:send", { room: "r1", body: "hi" });
const [message] = await received;
expect(message.body).toBe("hi");
a.close();
b.close();
});
Prueba también los caminos de fallo, ya que es ahí donde residen los bugs de tiempo real: un cliente no autenticado debería recibir connect_error, un payload incorrecto debería ser rechazado mediante un acuse de recibo (acknowledgement), y un socket que se desconecta debería abandonar sus salas. Utiliza una sala o namespace único por test para que las pruebas paralelas no vean los eventos de las demás, y cierra siempre los clientes para que el proceso del test finalice correctamente.
Desplegando un servidor de Socket.IO
Un despliegue de Socket.IO es básicamente un despliegue HTTP con dos requisitos adicionales: conexiones de larga duración y estado compartido. La mayoría de los imprevistos surgen al olvidar uno de ellos.
- Un solo puerto. Vincula Socket.IO al mismo servidor HTTP que tu API y termina el TLS en el proxy. No hay un puerto separado que exponer.
- Soporte de proxy para upgrades. Nginx y la mayoría de los balanceadores de carga necesitan una configuración explícita para pasar los encabezados
UpgradeyConnection; de lo contrario, la conexión se mantendrá silenciosamente en modo polling. - Sesiones persistentes (Sticky sessions). La afinidad basada en cookies mantiene el handshake de polling en una sola instancia. Si fuerzas el transporte exclusivo de WebSocket, la afinidad es menos crítica, pero el handshake aún debe completarse en algún lugar.
- Adaptador de Redis. Configúralo antes de que exista la segunda instancia, no después de que los usuarios reporten mensajes perdidos.
- Apagado gradual (Graceful shutdown). En
SIGTERM, deja de aceptar conexiones y cierra el servidor para que los eventos en curso puedan finalizar.
process.on("SIGTERM", async () => {
io.close(); // disconnects clients and stops the server
await pubClient.quit();
await subClient.quit();
httpServer.close();
});
upstream io_nodes {
ip_hash;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
Dimensiona cada instancia según las conexiones que soporte, no solo por el volumen de peticiones. Cada socket consume memoria y un descriptor de archivo, y una sola instancia con decenas de miles de conexiones fallará de maneras que un servicio basado en peticiones nunca haría. Escala horizontalmente pronto, monitorea el recuento de conexiones y otorga a los despliegues un periodo de gracia lo suficientemente largo para que los clientes se reconecten a una instancia saludable.
Cuándo los WebSockets puros son la mejor opción
Socket.IO no siempre es la respuesta. Elige el protocolo puro cuando:
- La interoperabilidad sea importante. Los clientes de WebSocket nativos, otros lenguajes y las herramientas estrictas de protocolo no pueden comunicarse mediante el handshake personalizado de Socket.IO.
- Necesites el cliente más ligero posible. Socket.IO requiere un bundle del cliente; un WebSocket puro ya viene integrado en el navegador.
- Tu infraestructura sea nativa de WebSocket. Algunos gateways, brokers y edge runtimes terminan conexiones de WebSockets pero no el fallback de polling de Socket.IO.
- Controles ambos extremos y no quieras abstracciones. Si las salas y la reconexión son triviales para tu caso de uso,
wses más sencillo de gestionar.
Por el contrario, elige Socket.IO cuando quieras salas, acknowledgements, reconexión automática y broadcasting multi-instancia sin tener que construirlos desde cero. Para la mayoría de los equipos de producto, esa lista representa todo el conjunto de funcionalidades de su capa de tiempo real, que es precisamente la razón por la cual existe la librería.
Mejores prácticas
- Autentica en
io.usey almacena el usuario ensocket.data, no en un closure. - Modela las rooms basándote en objetos reales del dominio: conversaciones, documentos, tenants.
- Usa acknowledgements para eventos cuyo resultado necesite el cliente.
- Prefiere
socket.to(room)cuando el remitente no deba recibir su propio evento. - Habilita la recuperación del estado de la conexión, pero mantén el estado persistente en una base de datos.
- Añade el adaptador de Redis antes de agregar una segunda instancia y configura sticky sessions.
- Emite desde handlers de HTTP y workers a través de rooms, no mediante socket ids almacenados.
- Valida, autoriza y aplica rate limit a cada evento.
- Limpia la presencia en
disconnecty usafetchSocketspara gestionar múltiples pestañas. - Configura
maxHttpBufferSize, los orígenes de CORS y los timeouts de forma explícita.
Errores comunes
- Llamar a
io.emitcuando solo una room debería recibir el evento. - Asumir que Socket.IO y WebSockets son intercambiables y luego fallar al conectar un cliente nativo.
- Confiar en
socket.handshake.authsin verificar el token. - Almacenar la presencia en un
Maplocal y perderla al usar un load balancer. - Olvidar las sticky sessions y romper el handshake de polling.
- Esperar que la reconexión repita los eventos sin una recuperación del estado de la conexión.
- Usar
socket.idcomo id de usuario y provocar errores al reconectar. - Realizar tareas pesadas o bloqueantes dentro de un event handler, ralentizando todos los sockets de la instancia.
- Dejar la validación del payload y el rate limiting delegados al frontend.
- Tratar el socket como almacenamiento duradero para cualquier dato que no deba perderse.
Próximos pasos
Socket.IO es la capa de alto nivel sobre el protocolo tratado en la guía de WebSockets, donde se explican los frames, los heartbeats y el handshake de actualización que ahora estás abstrayendo. La guía de Redis profundiza en el pub/sub y el estado compartido detrás del adapter, y Node.js Basics explica el event loop en el que se ejecuta cada handler. Si tu servidor de Socket.IO está vinculado a una API HTTP, la guía de Express cubre el routing y el middleware que comparten el mismo proceso.