Ce que Socket.IO apporte aux WebSockets
Socket.IO est une bibliothèque dédiée à la communication en temps réel basée sur des événements entre un serveur et ses clients. En arrière-plan, elle utilise le protocole WebSocket lorsque c’est possible et bascule sur le HTTP long polling dans le cas contraire. Par-dessus ce transport, elle vous offre un petit protocole d’application : des événements nommés, des rooms, des accusés de réception (acknowledgements), la reconnexion automatique et des heartbeats.
C’est précisément tout l’intérêt de cette couche supplémentaire. Une connexion WebSocket brute vous fournit un canal de communication, et rien d’autre. Chaque application réelle doit alors recréer les mêmes fonctionnalités : comment grouper les connexions par salon de discussion ou par tenant, comment savoir si un message a été reçu, comment se reconnecter proprement, comment diffuser un message sur plusieurs serveurs ? Socket.IO répond à ces questions une fois pour toutes, via une bibliothèque éprouvée, afin que vous puissiez vous concentrer sur votre produit.
Le compromis est que Socket.IO n’est pas un WebSocket standard. Il définit son propre handshake et son propre format de paquet au-dessus d’Engine.IO ; par conséquent, un client WebSocket natif ne peut pas se connecter à un serveur Socket.IO. Les deux parties doivent utiliser le client Socket.IO. Si vous avez besoin d’une interopérabilité avec des clients WebSocket arbitraires, utilisez plutôt le guide WebSockets et la bibliothèque ws.
La suite de ce guide part du principe que vous connaissez déjà le protocole brut et que vous souhaitez désormais utiliser la version “batteries-included”.
Configuration du serveur et du client
Le serveur s’attache à un serveur HTTP existant, qui est généralement le même que celui servant votre API. Cela signifie un seul port, un seul certificat TLS et aucune infrastructure supplémentaire.
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);
Le client se connecte avec la même origine et s’authentifie via le 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));
Le socket.id est un identifiant par connexion. Il est utile pour les logs et pour cibler une connexion spécifique, mais il change lors d’une reconnexion ; ne l’utilisez donc jamais comme identifiant utilisateur et ne le stockez pas comme un état durable.
Événements, accusés de réception et callbacks
Dans Socket.IO, tout est un événement nommé transportant une charge utile JSON. Le serveur et le client utilisent tous deux emit pour envoyer et on pour écouter. Les noms sont de simples chaînes de caractères, choisissez donc une convention et tenez-vous-y — noun:verb comme message:send, message:new et presence:joined est très lisible des deux côtés.
La fonctionnalité qui distingue Socket.IO d’un socket brut est l’accusé de réception (acknowledgement). Si l’émetteur passe un callback en dernier argument, le destinataire peut l’appeler pour répondre, transformant ainsi l’émission en une requête/réponse sur la même connexion.
// 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() });
});
Utilisez les accusés de réception pour tout événement dont le client doit connaître l’issue : création d’un enregistrement, jointure à une room, soumission d’un formulaire. Pour les diffusions pures (broadcasts) où personne n’attend de réponse, un simple emit suffit. Un compromis est socket.timeout(5000).emit(...), qui déclenche une erreur dans le callback si aucun accusé de réception n’arrive à temps.
Middleware et le pipeline d’événements
Socket.IO dispose de deux couches de middleware, et placer votre logique dans la bonne couche permet de garder vos handlers propres.
Le middleware de connexion, enregistré avec io.use ou namespace.use, s’exécute une seule fois par connexion avant l’événement connection. C’est ici que doivent se trouver l’authentification, la résolution du tenant et la configuration par connexion. Les middlewares s’exécutent dans l’ordre d’enregistrement, et chacun appelle next() pour continuer ou next(new Error(...)) pour rejeter la connexion.
io.use((socket, next) => {
const startedAt = Date.now();
socket.on("disconnect", () => {
metrics.observe("socket.duration", Date.now() - startedAt);
});
next();
});
Le middleware d’événement, enregistré avec socket.use, s’exécute pour chaque événement entrant sur ce socket. C’est l’endroit idéal pour la validation, le rate limiting et la journalisation structurée, car il a accès au nom de l’événement et à sa charge utile (payload) avant tout 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 observe chaque événement entrant, et socket.onAnyOutgoing observe tout ce que vous envoyez, ce qui vous permet d’obtenir une trace complète d’une connexion sans modifier un seul handler. Le middleware est également conscient des namespaces : le namespace /admin peut exiger un rôle différent sans affecter /chat.
Rooms et namespaces
Deux mécanismes de regroupement permettent de cibler les messages plutôt que de les diffuser à tout le monde.
Une room est une étiquette côté serveur appliquée à un ensemble de sockets. N’importe quelle socket peut rejoindre ou quitter n’importe quelle room à tout moment, et une socket peut appartenir à plusieurs rooms simultanément. Lorsque vous émettez un événement vers une room, seuls ses membres le reçoivent.
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 est un canal de communication distinct accessible via un chemin, tel que /chat ou /admin. Les namespaces possèdent leur propre middleware, leurs propres gestionnaires de connexion et leurs propres rooms. Utilisez les namespaces pour séparer des domaines qui ne doivent absolument pas partager d’événements — par exemple, un namespace pour un chat public et un autre pour l’administration interne — plutôt que pour modéliser des données au sein d’une même fonctionnalité.
Les rooms sont l’outil principal. Modélisez-les en fonction des éléments qui importent pour vos utilisateurs : une conversation, un document, un tenant, un tableau de bord. Ainsi, la diffusion devient une simple ligne de code au lieu d’une boucle sur toutes les connexions.
Diffusion et ciblage
Socket.IO utilise un vocabulaire précis pour définir qui reçoit un événement ; bien le maîtriser permet d’éviter à la fois les fuites de données et le gaspillage de ressources lors de la diffusion.
io.emit(...)— tous les sockets connectés. C’est rarement ce que l’on souhaite.socket.emit(...)— uniquement le socket qui gère l’événement actuel.socket.broadcast.emit(...)— tout le monde, sauf l’expéditeur.socket.to(room).emit(...)— tout le monde dans la room, sauf l’expéditeur.io.to(room).emit(...)— tout le monde dans la room, y compris l’expéditeur.io.to(roomA).to(roomB).emit(...)— l’union des deux rooms.socket.to(socketId).emit(...)— un socket spécifique via son id.
socket.on("typing", ({ room }) => {
// Tell the room, but not the person typing.
socket.to(room).emit("typing", { user: socket.data.user.id });
});
Le chaînage de to permet d’unir les destinataires ; il n’existe pas d’opérateur d’intersection dans l’API native. Si vous avez besoin de cibler les « membres de la room A qui sont aussi administrateurs », modélisez cela comme une room distincte — room:${id}:admins — plutôt que d’essayer de le calculer au moment de l’émission.
Authentifier le handshake
L’authentification doit avoir lieu lors du handshake de connexion, et non dans un premier message. Le middleware Socket.IO enregistré avec io.use s’exécute avant l’événement connection, ce qui vous permet de rejeter un socket non authentifié avant qu’il ne puisse émettre quoi que ce soit ou rejoindre un salon.
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"));
}
});
Deux détails sont importants. Premièrement, socket.data est l’endroit idéal pour attacher l’état propre à chaque connexion ; cet état persiste pendant toute la durée de la connexion et est disponible dans chaque handler. Deuxièmement, une connexion rejetée déclenche un événement connect_error côté client, permettant ainsi à l’UI de demander un nouveau jeton au lieu de tenter une reconnexion indéfiniment.
Pour les clients navigateurs, un cookie envoyé lors du handshake est une alternative à un jeton explicite, et vous permet de réutiliser votre infrastructure de session existante. Dans tous les cas, configurez toujours l’origine cors sur votre propre application, et autorisez chaque événement en fonction de l’utilisateur dans socket.data — l’authentification prouve qui s’est connecté, pas ce qu’il est autorisé à faire.
Reconnexion et récupération de l’état de connexion
Les connexions tombent. Les ordinateurs portables se mettent en veille, les téléphones changent de réseau, les load balancers redémarrent. Socket.IO gère la reconnexion automatiquement avec un backoff exponentiel et du jitter, et il émet les événements reconnect_attempt et reconnect pour vous permettre d’afficher l’interface utilisateur appropriée.
Par défaut, cependant, les événements envoyés pendant l’absence du client sont perdus. Le client se reconnecte comme un nouveau socket et reprend à partir de maintenant. C’est acceptable pour un flux en direct, mais incorrect pour un chat ou un document collaboratif.
La récupération de l’état de connexion (connection state recovery) résout le cas des interruptions courtes. Activez-la sur le serveur, et un client qui se reconnecte en présentant son session id recevra les paquets qu’il a manqués, à condition que la déconnexion ait été brève.
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
});
La récupération est délibérément limitée : elle conserve les paquets récents en mémoire pendant une courte fenêtre et uniquement sur l’instance qui a géré la connexion originale ; ce n’est donc pas un substitut à un stockage durable. Tout ce qui doit survivre à une panne plus longue — messages, commandes, modifications de documents — doit être stocké dans une base de données, le socket ne servant qu’à notifier les clients qu’un changement a eu lieu.
Passer à l’échelle avec l’adaptateur Redis
Un serveur Socket.IO conserve ses sockets et ses rooms en mémoire. Si vous lancez deux instances derrière un load balancer, elles se comportent comme deux applications temps réel distinctes : un message émis sur l’instance A n’atteindra jamais les clients de l’instance B.
L’adaptateur Redis règle ce problème. Chaque instance publie ses broadcasts sur un canal Redis pub/sub et s’abonne à ce même canal ; ainsi, un emit sur une instance est délivré aux sockets correspondantes partout.
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),
});
Deux notes opérationnelles. Premièrement, les sticky sessions restent nécessaires pendant la phase de polling HTTP, car le handshake Engine.IO s’étend sur plusieurs requêtes qui doivent impérativement atteindre la même instance. Configurez le load balancer pour une affinité basée sur les cookies, ou forcez l’utilisation exclusive du transport WebSocket. Deuxièmement, l’adaptateur utilise Redis pub/sub, ce qui est rapide mais non durable : un message publié pendant qu’une instance redémarre sera perdu. Redis fait également l’objet de son propre guide Redis.
Pour les déploiements très volumineux, il existe un adaptateur sharded qui répartit les canaux sur un cluster Redis, ainsi que des adaptateurs pour d’autres brokers, mais commencez par l’adaptateur standard.
Émettre depuis l’extérieur d’un socket
Les événements en temps réel proviennent rarement de l’intérieur d’un gestionnaire de connexion. Une requête HTTP crée un commentaire, un worker termine un rapport, un webhook confirme un paiement — tous ces événements doivent notifier les clients connectés.
Gardez une référence vers io et émettez vers une room depuis n’importe où :
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);
});
Les rooms sont l’outil idéal ici car elles sont partagées entre les instances lorsque l’adapter est configuré. Pour atteindre un utilisateur spécifique, émettez vers une room propre à l’utilisateur — user:${id} — que le socket a rejointe lors de la connexion, plutôt que de suivre les socket ids. Cela continue de fonctionner même si l’utilisateur a deux onglets ouverts, se reconnecte ou atterrit sur une instance différente.
Si vous avez réellement besoin d’un socket id, io.in(room).fetchSockets() renvoie les sockets actifs dans une room, ce qui est utile pour le comptage de présence et les envois ciblés.
Présence, indicateurs de saisie et fonctionnalités collaboratives
L’utilisation de rooms combinée à un store partagé couvre la majorité des fonctionnalités collaboratives. La gestion de la présence — savoir qui est en ligne — est le cas le plus courant, et une version naïve cesse de fonctionner dès que vous lancez plus d’une instance.
Le pattern consiste à maintenir la présence faisant foi dans Redis, et non dans une map JavaScript, tout en effectuant un nettoyage lors de la déconnexion :
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 vérification fetchSockets est cruciale : un utilisateur ayant deux onglets ouverts ne doit pas apparaître hors ligne lorsqu’il ferme l’un d’entre eux. Les indicateurs de saisie, les positions du curseur et les flags “quelqu’un est en train de modifier” suivent la même logique : un état éphémère diffusé à une room, avec une expiration courte pour éviter qu’un client planté ne laisse un indicateur obsolète indéfiniment.
Pour l’édition collaborative réelle avec résolution de conflits, tournez-vous vers une bibliothèque CRDT telle que Yjs et envoyez ses mises à jour via le socket, plutôt que d’inventer votre propre algorithme de fusion.
Validation, limitation du débit et sécurité
Un socket est un canal d’entrée non fiable, exactement comme une requête HTTP. Considérez chaque payload d’événement comme hostile jusqu’à ce qu’il soit validé.
- Validez les structures. Analysez les payloads avec une bibliothèque de schéma comme Zod avant de les manipuler, et rejetez-les avec un accusé de réception au lieu de lever une exception.
- Limitez le débit (Rate limiting). Un socket peut émettre des milliers d’événements par seconde. Utilisez un système de “token bucket” par socket et déconnectez ou bridez les utilisateurs abusifs.
- Plafonnez la taille des payloads.
maxHttpBufferSizedéfinit la quantité de données qu’un seul message peut transporter. - Autorisez chaque événement. Vérifiez à nouveau que l’utilisateur dans
socket.dataest autorisé à agir sur la room ou la ressource, et ne vous contentez pas de vérifier qu’il est connecté. - Validez l’origine. Configurez
cors.originet ne le laissez pas ouvert en production. - Gérez les timeouts. Les heartbeats et les timeouts d’inactivité empêchent les fuites de connexions abandonnées.
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();
});
});
Le middleware par socket enregistré avec socket.use est l’endroit idéal pour centraliser ces vérifications, afin que les handlers individuels restent concentrés sur la logique métier.
Débogage et observabilité
Le premier outil est intégré. Le fait de configurer DEBUG=socket.io:* (ou engine*) affiche le handshake, les montées en version du transport (transport upgrades) et le flux de paquets, ce qui est généralement suffisant pour diagnostiquer un client qui ne parvient pas à se connecter.
Pour la production, suivez les signaux qui prédisent réellement les incidents :
- Sockets connectées — une chute soudaine indique un déploiement, un crash ou une partition réseau.
- Événements par seconde, par nom — un pic sur un événement spécifique est souvent le signe d’une boucle infinie côté client.
- Raisons de déconnexion —
ping timeoutettransport closesignalent des problèmes de réseau ou de proxy, tandis queio server disconnectsignifie que votre code a fermé la socket. - Santé de l’adapter — si le pub/sub Redis est lent ou déconnecté, les diffusions entre instances s’arrêtent sans qu’aucune socket ne semble échouer.
io.on("connection", (socket) => {
socket.on("disconnect", (reason) => {
metrics.increment("socket.disconnect", { reason });
});
});
Le package @socket.io/admin-ui ajoute un tableau de bord basé sur ces mêmes données ; il est pertinent de l’exécuter en staging pour visualiser les rooms et les sockets en temps réel. Comme pour tout système temps réel, les pannes les plus déroutantes sont partielles : une instance fonctionne correctement alors qu’une autre ne fonctionne pas, et seule une vue par instance permet de le révéler.
Tester un serveur en temps réel
Le code temps réel est testable. Démarrez le serveur sur un port éphémère, connectez quelques instances socket.io-client et vérifiez les événements qu’elles reçoivent. La discipline essentielle consiste à attendre les événements plutôt qu’à utiliser des pauses (sleep), afin que les tests soient rapides et déterministes.
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();
});
Testez également les scénarios d’échec, car c’est là que se cachent les bugs du temps réel : un client non authentifié devrait recevoir connect_error, une charge utile (payload) incorrecte devrait être rejetée par un accusé de réception, et un socket qui se déconnecte devrait quitter ses salons. Utilisez un salon ou un espace de noms (namespace) unique par test pour que les tests parallèles ne voient pas les événements des autres, et fermez toujours les clients pour que le processus de test puisse s’arrêter.
Déployer un serveur Socket.IO
Le déploiement de Socket.IO est un déploiement HTTP avec deux exigences supplémentaires : des connexions persistantes et un état partagé. La plupart des imprévus proviennent de l’oubli de l’un de ces points.
- Un seul port. Attachez Socket.IO au même serveur HTTP que votre API et gérez la terminaison TLS au niveau du proxy. Il n’y a pas de port séparé à exposer.
- Support du proxy pour les upgrades. Nginx et la plupart des load balancers nécessitent une configuration explicite pour transmettre les headers
UpgradeetConnection; sans cela, la connexion reste silencieusement en mode polling. - Sessions collantes (Sticky sessions). L’affinité basée sur les cookies permet de maintenir le handshake de polling sur une seule instance. Si vous forcez le transport WebSocket-only, l’affinité est moins critique, mais le handshake doit tout de même s’achever quelque part.
- Adaptateur Redis. Configurez-le avant même que la deuxième instance n’existe, et non après que les utilisateurs aient signalé des messages manquants.
- Arrêt progressif (Graceful shutdown). Sur
SIGTERM, arrêtez d’accepter de nouvelles connexions et fermez le serveur pour permettre aux événements en cours de se terminer.
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;
}
Dimensionnez chaque instance en fonction des connexions qu’elle supporte, et pas seulement selon le débit de requêtes. Chaque socket consomme de la mémoire et un descripteur de fichier, et une instance unique avec des dizaines de milliers de connexions échouera d’une manière qu’un service basé sur des requêtes ne connaîtrait jamais. Passez à l’échelle horizontalement dès que possible, surveillez le nombre de connexions et accordez aux déploiements une période de grâce suffisamment longue pour que les clients puissent se reconnecter à une instance saine.
Quand les WebSockets bruts sont le meilleur choix
Socket.IO n’est pas toujours la solution idéale. Privilégiez le protocole brut lorsque :
- L’interopérabilité est primordiale. Les clients WebSocket natifs, les autres langages et les outils de protocole stricts ne peuvent pas gérer le handshake personnalisé de Socket.IO.
- Vous avez besoin du client le plus léger possible. Socket.IO nécessite l’installation d’un bundle client, alors que les WebSockets bruts sont intégrés nativement au navigateur.
- Votre infrastructure est nativement compatible WebSocket. Certaines passerelles, brokers et runtimes edge terminent les connexions WebSocket, mais ne supportent pas le fallback de polling de Socket.IO.
- Vous contrôlez les deux extrémités et ne voulez aucune abstraction. Si la gestion des rooms et la reconnexion sont triviales pour votre cas d’usage,
wsest plus simple à appréhender.
À l’inverse, choisissez Socket.IO si vous avez besoin de rooms, d’accusés de réception (acknowledgements), de reconnexion automatique et de diffusion multi-instances sans avoir à les développer vous-même. Pour la plupart des équipes produit, cette liste représente l’intégralité des fonctionnalités de leur couche temps réel, et c’est précisément pour cela que cette bibliothèque existe.
Bonnes pratiques
- Authentifiez-vous dans
io.useet stockez l’utilisateur danssocket.data, et non dans une closure. - Modélisez vos rooms selon des objets de domaine réels — conversations, documents, tenants.
- Utilisez des acquittements (acknowledgements) pour les événements dont le client a besoin du résultat.
- Privilégiez
socket.to(room)lorsque l’expéditeur ne doit pas recevoir son propre événement. - Activez la récupération de l’état de connexion, mais conservez l’état durable dans une base de données.
- Ajoutez l’adaptateur Redis avant d’ajouter une seconde instance, et configurez les sessions collantes (sticky sessions).
- Émettez depuis les handlers HTTP et les workers via des rooms, et non via des socket ids stockés.
- Validez, autorisez et limitez le débit (rate limit) de chaque événement.
- Nettoyez la présence dans
disconnectet utilisezfetchSocketspour gérer les onglets multiples. - Définissez explicitement
maxHttpBufferSize, les origines CORS et les timeouts.
Erreurs courantes
- Appeler
io.emitalors que seul un room devrait recevoir l’événement. - Supposer que Socket.IO et WebSockets sont interchangeables, et échouer lors de la connexion d’un client natif.
- Faire confiance à
socket.handshake.authsans vérifier le token. - Stocker la présence dans un
Maplocal et la perdre derrière un load balancer. - Oublier les sticky sessions et casser le handshake du polling.
- S’attendre à ce que la reconnexion rejoue les événements sans récupération de l’état de connexion.
- Utiliser
socket.idcomme identifiant utilisateur, puis subir un crash lors de la reconnexion. - Effectuer des tâches lourdes ou bloquantes à l’intérieur d’un gestionnaire d’événements, ce qui ralentit tous les sockets de l’instance.
- Laisser la validation du payload et le rate limiting au frontend.
- Traiter le socket comme un stockage durable pour des données qui ne doivent pas être perdues.
Pour aller plus loin
Socket.IO est une couche de haut niveau basée sur le protocole abordé dans le guide WebSockets, qui explique les frames, les heartbeats et le handshake d’upgrade que vous abstrayez désormais. Le guide Redis approfondit le pub/sub et l’état partagé derrière l’adapter, tandis que Node.js Basics explique l’event loop sur laquelle s’exécute chaque handler. Si votre serveur Socket.IO est rattaché à une API HTTP, le guide Express couvre le routing et le middleware avec lesquels il partage le même processus.