Accueil / Articles / Concevoir des backends de chat en temps réel : salles, persistance et mise à l’échelle

Concevoir des backends de chat en temps réel : salles, persistance et mise à l’échelle

Apprenez à concevoir un backend de chat en temps réel à l’aide de Socket.IO, PostgreSQL et Redis, en abordant les salons, l’ordre de persistance des messages, la présence des utilisateurs et le scaling multi-serveur.

2380 mots

La communication en temps réel change la manière dont un serveur et un client interagissent, allant au-delà d’une simple demande-réponse pour entrer dans un monde où l’on utilise des WebSockets, des salles de discussion, une persistance des messages, un suivi de la présence et un scaling basé sur Redis.

La plupart des API suivent un schéma prévisible :

Client
   ↓
HTTP Request
   ↓
Server
   ↓
HTTP Response

Le client envoie une demande. Le serveur renvoie une réponse. C’est toute l’interaction.

Mais imaginez maintenant ce qu’il faudrait pour créer quelque chose comme :

  • WhatsApp
  • Slack
  • Discord
  • Fichiers de notifications en direct
  • Indicateurs de présence en ligne
  • Indicateurs d’écriture
  • Tableaux de bord en direct
  • Fonctionnalités multijoueurs

Dans ces cas, on ne veut pas que le client vérifie sans cesse :

">Y a-t-il eu des changements ?"

Plutôt, on veut que le serveur lui-même puisse annoncer :

« Quelque chose a changé. Voici la mise à jour. »

C’est précisément le problème que résout la communication en temps réel.

1. HTTP vs Communication en temps réel

Le polling traditionnel HTTP se présente comme ceci :

Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."

Cela fonctionne, mais cela gaspille des requêtes et de la bande passante pour vérifier des mises à jour qui ne sont généralement pas présentes.

Client ←────────────→ Server
       connection
Server → New message
Server → User online
Server → Typing...
Server → Message read

Une fois la connexion ouverte, le serveur peut envoyer directement des événements au client chaque fois qu’il se produit un changement.

WebSockets sont une technologie largement utilisée pour permettre ce type de connexion. Dans l’écosystème Node.js, Socket.IO est une bibliothèque populaire conçue spécifiquement pour la communication en temps réel.

2. Une architecture simple

                 ┌──────────────┐
                 │ Web / Mobile │
                 └──────┬───────┘
                        │
                    WebSocket
                        │
                        ▼
                 ┌──────────────┐
                 │   Node.js    │
                 │ Socket.IO    │
                 └──────┬───────┘
                        │
             ┌──────────┴──────────┐
             ▼                     ▼
       ┌───────────┐         ┌───────────┐
       │ PostgreSQL│         │   Redis   │
       │ Messages  │         │ Pub/Sub   │
       └───────────┘         └───────────┘

Node.js + Socket.IO

Gère les connexions en temps réel ainsi que les événements qui y circulent.

PostgreSQL

Stocke les messages et l’historique des conversations.

Redis

Est utilisé lorsque vous exécutez plusieurs instances de votre application et que vous avez besoin qu’elles restent synchronisées pour les événements en temps réel.

3. Mise en place de Socket.IO

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);
  });
});

4. Les événements sont le concept central

"Call this endpoint"

"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"

Par exemple, le serveur peut émettre :

socket.emit("message-created", {
  id: message.id,
  text: message.text,
});

Et le client écoute ce même événement :

socket.on("message-created", (message) => {
  console.log("New message:", message);
});

Ce passage à un modèle basé sur les événements est l’un des moyens fondamentaux par lesquels les applications en temps réel diffèrent des API conventionnelles basées sur REST.

5. Les salles rendent les systèmes de chat beaucoup plus simples

Imaginez une conversation un à un :

User A
User B

On ne veut pas diffuser chaque message à tous les utilisateurs connectés, mais uniquement à ceux qui participent réellement à cette conversation.

Socket.IO permet de regrouper des sockets dans une salle :

socket.join(`conversation:${conversationId}`);

Puis, chaque fois qu’un nouveau message est créé, il est émis vers cette salle spécifique :

io.to(`conversation:${conversationId}`)
  .emit("message-created", message);

Seuls les sockets qui ont rejoint cette salle recevront l’événement.

Même principe pour regrouper des conversations. Une salle comme :

conversation:123

peut contenir plusieurs participants :

User A
User B
User C
User D

Et un seul message émis parvient à tous ceux qui se trouvent dans cette pièce en même temps.

6. Ne pas sauvegarder le message après diffusion

C’est un choix de conception qui mérite d’être réfléchi soigneusement.

Une séquence risquée serait :

Receive message
      ↓
Broadcast message
      ↓
Save to database

Le problème : que se passe-t-il si l’écriture dans la base de données échoue après que le message a déjà été envoyé ? Les utilisateurs auraient vu un message qui n’a jamais vraiment été enregistré, ce qui crée une incohérence entre ce que les gens voient et ce qui est stocké.

Un modèle plus fiable consiste à persister les données en premier, puis à diffuser le message :

Client
  ↓
send-message
  ↓
Validate
  ↓
Save to PostgreSQL
  ↓
Database succeeds
  ↓
Broadcast event

En pratique, cela ressemble à ceci :

socket.on("send-message", async (data) => {
  const message = await saveMessage(data);

io.to(`conversation:${data.conversationId}`)
    .emit("message-created", message);
});

Les garanties de durabilité exactes dont vous avez besoin varient en fonction de l’application, mais le principe fondamental reste valable : la manière dont les messages sont persistés et livrés doit être une décision délibérée, et non une considération ultérieure.

7. Stocker l’historique des conversations dans PostgreSQL

Construire un système basé sur des événements en temps réel ne signifie pas que tout doit exister uniquement en mémoire.

Les utilisateurs s’attendent à ce que, lorsqu’ils ouvrent une conversation le lendemain, leurs messages précédents soient toujours là.

Un schéma simplifié pourrait ressembler à ceci :

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()
);

Ensuite, chaque fois qu’un utilisateur ouvre une conversation, on exécuterait quelque chose comme ceci :

SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;

À ce stade, nous avons divisé le travail en deux responsabilités distinctes :

Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history

Préserver cette séparation entre les différentes tâches est très important.

8. Ajouter un index pour l’historique des conversations

Si votre application exécute régulièrement une requête de ce type :

WHERE conversation_id = ?
ORDER BY created_at DESC

alors votre schéma de base de données doit être conçu en tenant ce modèle à l’esprit.

Par exemple :

CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);

L’idée n’est pas de disperser des index partout sans réfléchir.

Et, pour reprendre un point déjà abordé :

Mesurez toujours les performances avant et après avoir apporté la modification.

9. La présence en ligne est différente du stockage des messages

Disons que vous souhaitez afficher quelque chose comme :

Mit
● Online

Il n’y a pas besoin de conserver en mémoire permanente quelque chose comme :

user.is_online = true

dans PostgreSQL à chaque connexion d’un utilisateur.

Pourquoi pas ?

Parce que l’état de présence change constamment.

Pour la plupart des systèmes, il est préférable de stocker ce type de données de présence à courte durée de vie dans Redis.

Par exemple :

online:user:123
TTL → 60 seconds

Le client peut envoyer des signaux périodiques pour indiquer qu’il reste actif.

Lorsque ces battements de cœur cessent d’arriver, la clé de présence expire automatiquement.

Cela empêche que l’état de connexion temporaire ne soit confondu avec des données permanentes dignes d’être stockées dans une base de données.

10. Les indicateurs de frappe sont encore plus temporaires

Prenons par exemple :

Mit is typing...

Faut-il une ligne dans PostgreSQL pour cela ?

Definitivement pas.

C’est purement éphémère.

Un événement de socket suffit :

socket.to(roomId).emit("user-typing", {
  userId,
});

Et une fois que l’utilisateur cesse de taper :

socket.to(roomId).emit("user-stopped-typing", {
  userId,
});

Cela souligne un principe de conception plus général :

Tout ce que votre application traque n’a pas besoin d’être stocké dans une base de données.

Un bon test consiste à vérifier si les informations doivent survivre à un redémarrage du serveur.

Si ce n’est pas le cas, un type de stockage éphémère est probablement plus adapté.

11. Le problème avec plusieurs serveurs Node.js

C’est ici que les choses deviennent plus complexes.

Imaginez une configuration avec un seul serveur Node.js :

Client
   ↓
Node.js

À cette échelle, tout fonctionne sans problème.

Mais ensuite, le trafic augmente.

La configuration ressemble alors à ceci :

              Load Balancer
                /       \
               ↓         ↓
          Node.js A   Node.js B

L’utilisateur A se connecte à Node.js A.

L’utilisateur B se connecte à Node.js B.

Maintenant, l’utilisateur A envoie un message.

Comment Node.js B doit-il savoir qu’il doit livrer cet événement à l’utilisateur B ?

C’est précisément ce type de problème qui nécessite une couche de messagerie partagée entre les instances serveur.

12. Redis peut connecter plusieurs instances

Une solution courante est Redis, associé à l’adaptateur Socket.IO pour Redis.

Conceptuellement, cela se présente comme suit :

                Load Balancer
                 /         \
                ↓           ↓
          Node.js A     Node.js B
                \           /
                 \         /
                   Redis

Avec cette solution en place, un événement créé sur une instance serveur peut être propagé aux autres instances.

C’est ce qui permet à la couche en temps réel de dépasser les limites d’un seul processus Node.js.

Il convient de préciser que Redis n’est pas un remplaçant pour PostgreSQL dans ce contexte.

Ces deux outils servent à des fins différentes.

PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination

13. L’authentification reste importante

Établir une connexion WebSocket ne signifie pas automatiquement que cette connexion peut être considérée comme fiable.

L’authentification reste nécessaire.

Une approche courante consiste pour le client à se connecter en présentant un type de jeton.

Au préalable d’accorder l’accès aux conversations privées, le serveur doit valider ce jeton.

Conceptuellement, le flux se présente comme suit :

Client
  ↓
Connection
  ↓
Authenticate
  ↓
Validate user
  ↓
Allow socket connection

Puis, lorsque un client tente de rejoindre une salle comme :

conversation:123

le serveur doit confirmer que l’utilisateur authentifié est bien un participant à cette conversation.

N’acceptez jamais simplement :

socket.join(conversationId);

en partant du principe que l’ID fourni par le client peut être considéré comme fiable tel quel.

14. Gérer les déconnexions

Les interruptions de connexion inattendues sont une réalité constante dans les systèmes en temps réel.

  • Fermer son navigateur
  • Perdre sa connexion Wi-Fi
  • Changer de réseau
  • Faire entrer son téléphone en mode veille
  • Perdre le signal mobile
  • Fermer manuellement l’application

C’est pourquoi votre serveur a besoin d’une logique telle que :

socket.on("disconnect", (reason) => {
  console.log("Disconnected:", reason);
});

La logique de réconnexion doit également être prise en compte.

Une interruption réseau de cinq secondes ne doit pas signifier que l’utilisateur perd définitivement accès aux fonctionnalités en temps réel.

C’est d’ailleurs l’une des raisons pour lesquelles les systèmes en temps réel exigent généralement une gestion de l’état plus rigoureuse que celle d’une API REST typique.

15. Le temps réel ne nécessite pas que tout repose sur WebSockets

Voici un autre point clé à retenir.

Aucune règle n’exige que toute votre application soit reconstruite autour de connexions WebSocket.

REST API
+
WebSockets
+
PostgreSQL
+
Redis

Par exemple :

REST

Préférez REST pour gérer :

Login
Get conversation history
Create conversation
Upload files
Search messages

WebSocket

Utilisez les événements WebSocket pour gérer :

New message
Typing indicator
Online status
Read receipts
Live notifications

Cette configuration hybride a tendance à simplifier les choses bien plus que de tenter de faire passer tout le trafic par des sockets.

16. Points à considérer pour la production

Passer au temps réel ajoute une nouvelle couche de préoccupations.

Vous devrez tenir compte de :

Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance

Et si vous développez quelque chose qui ressemble davantage à un produit de messagerie complet, ajoutez également :

Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination

Le champ des problèmes s’élargit rapidement.

C’est précisément pourquoi la première version ne devrait pas essayer de résoudre tout en même temps.

17. Un point de départ raisonnable

Pour une application plus petite, une architecture de départ sensée ressemble à ceci :

             Client
                │
                ▼
        ┌───────────────┐
        │    Node.js    │
        │  REST + WS    │
        └───────┬───────┘
                │
         ┌──────┴──────┐
         ▼             ▼
    PostgreSQL       Redis
    Messages         Cache /
    Users            Presence

Vous pourrez l’élargir ultérieurement lorsque cela deviendra vraiment nécessaire :

                 Load Balancer
                 /           \
                ▼             ▼
          Node.js A       Node.js B
                \             /
                 \           /
                    Redis
                      │
                      ▼
                 PostgreSQL

Gardez la première version simple. Évaluez ses performances dans des conditions réelles d’utilisation. Ce n’est qu’alors que vous devrez optimiser les éléments qui se révèlent être de véritables goulots d’étranglement.

18. Une liste de contrôle pour les backends en temps réel

[ ] 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

Réflexion finale

D’un point de vue extérieur, créer une fonctionnalité de chat semble simple.

"Hello"

Et quelqu’un d’autre reçoit :

"Hello"

Mais derrière ces deux mots se cache toute une série de défis techniques :

Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling

Ce niveau de complexité caché est précisément ce qui rend les systèmes en temps réel digne d’une compréhension approfondie.

La leçon principale à retenir est la suivante :

Résistez à l’envie de créer un système massivement distribué dès le premier jour.

Commencez par :

Node.js
+
Socket.IO
+
PostgreSQL

Comprenez comment cette combinaison se comporte dans des conditions réelles.

N’intégrez Redis et d’autres instances que lorsque des exigences réelles le rendent nécessaire.

Gardez la première version minimale. Prenez le temps de comprendre comment les éléments clés fonctionnent ensemble. Observez comment le système résiste à une charge réelle. Ne développez que les parties de l’installation qui ont vraiment besoin d’une plus grande capacité.

C’est ainsi qu’une fonction de chat de base se transforme en un backend en temps réel fiable.

Lectures complémentaires

  • Construire des systèmes de taches en arrière-plan fiables avec BullMQ et Redis — Apprenez à concevoir des pipelines de taches en arrière-plan résilients pour Node.js à l’aide de BullMQ et Redis, en abordant les tentatives répétées, la concurrence, l’idempotence et le suivi.
  • Choisir le transport et l’architecture pour des applications JavaScript en temps réel — Comment WebSockets, Socket.IO, WebRTC, les brokers de messages, les régions périphériques et le suivi s’intègrent lors de la création d’applications de chat, de diffusion en direct ou de jeux multijoueurs en JavaScript.