Aller au contenu

Le journal · Développement et ingénierie

WebSocket ou SSE : quelle solution pour mettre à jour votre app en temps réel ?

Polling, long polling, Server-Sent Events, WebSocket ou WebTransport ? Comment choisir la bonne technique pour mettre à jour votre page ou votre app en temps réel, y compris sur mobile.

← Le journal13 min de lecturePar ScreamZ

Votre page doit se mettre à jour toute seule. Une notification qui arrive, une commande qui passe de « en préparation » à « expédiée », un dashboard qui bouge, un message de chat, l'adversaire qui se déplace dans un jeu. L'utilisateur ne doit pas avoir à rafraîchir.

Le réflexe le plus courant, c'est « on met des WebSockets ». C'est souvent un bon choix, mais pas toujours le meilleur. Il existe cinq façons de faire du temps réel dans un navigateur, et la bonne dépend de trois questions :

  • Qui parle ? Le serveur seul, ou le client et le serveur à égalité ?
  • À quelle fréquence ? Quelques messages par minute, ou des dizaines par seconde ?
  • Sur quel appareil ? Un poste de bureau toujours connecté, ou un téléphone qui passe son temps en arrière-plan ?

Pressé ? Allez directement au tableau récapitulatif et à l'arbre de décision en fin d'article.

Les cinq approches en bref

Polling

Le client redemande au serveur « quoi de neuf ? » à intervalle régulier, par exemple toutes les 5 secondes.

C'est la solution la plus simple : une requête HTTP classique dans un setInterval, rien à changer côté serveur ni côté infrastructure. Mais ce n'est pas du temps réel. Le délai moyen est de la moitié de l'intervalle, et la grande majorité des requêtes reviennent vides. Avec beaucoup d'utilisateurs, ça coûte cher pour pas grand-chose.

Long polling

Le client envoie une requête, et le serveur la garde ouverte jusqu'à ce qu'il ait quelque chose à répondre (ou qu'un délai expire). Dès que la réponse arrive, le client renvoie immédiatement une nouvelle requête.

On obtient une latence proche du temps réel avec du HTTP pur, qui passe partout. C'était la technique des premiers chats web et de Comet. Mais c'est un contournement : une requête complète, avec ses en-têtes, pour chaque message, et une gestion de l'ordre et des messages perdus entre deux requêtes qui reste à votre charge. Aujourd'hui, on le garde surtout comme solution de repli.

Server-Sent Events (SSE)

Le client ouvre une requête HTTP, et le serveur ne la termine jamais. Il y écrit des événements au fil de l'eau, dans un format texte très simple (text/event-stream). Côté navigateur, l'API EventSource s'occupe de tout.

C'est un flux unidirectionnel : du serveur vers le client. Pour parler au serveur, le client utilise des requêtes HTTP classiques, à côté.

WebSocket

Le client ouvre une connexion HTTP, puis demande à la « transformer » (Upgrade: websocket). Une fois le handshake passé, ce n'est plus du HTTP : c'est un canal bidirectionnel et persistant, sur lequel client et serveur s'envoient des messages quand ils veulent, dans les deux sens.

WebTransport

Le petit nouveau. Il repose sur HTTP/3 et QUIC (donc sur UDP), et offre plusieurs flux indépendants sur une même connexion, ainsi que des datagrammes « non fiables » : des messages qui peuvent être perdus, mais qui ne bloquent jamais les suivants. C'est exactement ce que veulent le jeu en ligne et la vidéo temps réel.

Depuis Safari 26.4 (mars 2026), il est disponible dans tous les navigateurs majeurs. Mais c'est très récent : les implémentations divergent encore sur certains détails, l'écosystème côté serveur est mince, et l'UDP est parfois bloqué sur les réseaux d'entreprise. Il faut donc prévoir un repli.

La vraie question : unidirectionnel ou bidirectionnel ?

Le critère qui sépare vraiment SSE et WebSocket, c'est la direction des échanges.

Il est tentant de se dire « mon client envoie des données au serveur, donc il me faut du bidirectionnel, donc des WebSockets ». Mais regardez ce que fait vraiment votre app. Dans la grande majorité des cas, le client écoute beaucoup et parle peu : il reçoit des notifications, des mises à jour de statut, des nouvelles données. Et quand il parle (envoyer un message, cliquer sur un bouton, valider un formulaire), une requête POST classique fait parfaitement l'affaire.

La bonne question n'est donc pas « est-ce que le client envoie des données ? ». C'est : « est-ce que le client envoie des petits messages, très souvent, et en attend une réponse immédiate ? »

  • Si non, vous avez un flux serveur → client, avec quelques actions ponctuelles du client. C'est le terrain de SSE.
  • Si oui, vous avez un vrai dialogue, rapide et symétrique. C'est le terrain de WebSocket.

SSE : le bon choix par défaut pour une page qui se met à jour

Du HTTP, et rien d'autre

La grande force de SSE, c'est que c'est du HTTP standard du début à la fin. Une simple réponse qui ne se termine pas. Concrètement :

  • ça passe à travers les proxys, les load balancers et les pare-feu d'entreprise sans configuration particulière ;
  • ça profite du multiplexage HTTP/2 et HTTP/3 : plusieurs flux SSE partagent la même connexion ;
  • l'authentification par cookie, les en-têtes CORS, la compression, les logs : tout fonctionne comme pour le reste de votre API.

Le format

Le serveur écrit des blocs de texte séparés par une ligne vide :

text
retry: 5000

id: 41
event: order-status
data: {"orderId":"A12","status":"preparing"}

id: 42
event: order-status
data: {"orderId":"A12","status":"shipped"}

: ping
  • data contient le message (souvent du JSON) ;
  • event donne un nom à l'événement, pour l'écouter spécifiquement côté client ;
  • id numérote l'événement ;
  • retry indique au navigateur combien de millisecondes attendre avant de se reconnecter ;
  • une ligne qui commence par : est un commentaire, ignoré par le client. Pratique pour garder la connexion active.

Ce qu'on obtient sans rien coder

C'est là que SSE prend l'avantage. L'API EventSource du navigateur gère pour vous :

  • la reconnexion automatique. Si la connexion tombe, le navigateur se reconnecte tout seul, après le délai fixé par retry ;
  • la reprise des messages manqués. À la reconnexion, le navigateur envoie automatiquement l'en-tête Last-Event-ID avec le dernier id reçu. Le serveur sait exactement où le client s'était arrêté et peut lui renvoyer ce qu'il a raté ;
  • un état de connexion fiable, via readyState : CONNECTING, OPEN ou CLOSED.

Côté client, il n'y a presque rien à écrire :

javascript
const source = new EventSource("/api/orders/A12/events");

source.addEventListener("order-status", (event) => {
  const { status } = JSON.parse(event.data);
  renderStatus(status);
});

source.addEventListener("error", () => {
  if (source.readyState === EventSource.CONNECTING) {
    showBanner("Reconnexion en cours…");
  }
});

source.addEventListener("open", () => hideBanner());

Côté serveur, quel que soit votre langage, il suffit de répondre avec le bon Content-Type, de ne jamais fermer la réponse, et d'écrire au bon format. Un exemple minimal avec le module http de Node.js, sans dépendance :

javascript
import { createServer } from "node:http";

createServer((req, res) => {
  if (req.url !== "/events") return res.writeHead(404).end();

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });

  // Fourni automatiquement par le navigateur à chaque reconnexion
  const lastEventId = Number(req.headers["last-event-id"] ?? 0);
  // → ici, renvoyer les événements dont l'id est supérieur à lastEventId

  let id = lastEventId;
  const timer = setInterval(() => {
    id += 1;
    res.write(`id: ${id}\nevent: tick\ndata: ${JSON.stringify({ at: Date.now() })}\n\n`);
  }, 1000);

  req.on("close", () => clearInterval(timer));
}).listen(3000);

Un point à connaître : le navigateur ne se reconnecte que si la connexion a été coupée. Si le serveur répond avec un code autre que 200, ou un mauvais Content-Type, EventSource passe en CLOSED et ne réessaie plus. C'est d'ailleurs le moyen propre de dire au client d'arrêter, par exemple avec un 204 No Content quand la commande est livrée.

Les cas d'usage typiques

  • notifications et badges ;
  • suivi de statut : commande, livraison, paiement, traitement en arrière-plan ;
  • barres de progression : import de fichier, export, génération de rapport ;
  • dashboards et flux de données en lecture ;
  • streaming de réponses d'IA, mot par mot. C'est d'ailleurs ce qu'utilisent la plupart des API de LLM.

WebSocket : quand il y a un vrai dialogue rapide

Fait pour les petits messages fréquents

Une fois la connexion établie, chaque message WebSocket ne porte que quelques octets d'en-tête (de 2 à 14), contre plusieurs centaines pour une requête HTTP. Les messages circulent dans les deux sens, à tout moment, sans aller-retour. Et le protocole transporte du binaire nativement, là où SSE est limité au texte.

C'est donc l'outil idéal quand le client et le serveur échangent beaucoup de petits messages, très souvent :

  • jeu vidéo et application multijoueur : positions, actions, états de partie ;
  • édition collaborative : curseurs, frappes, sélections des autres utilisateurs ;
  • chat très interactif : « en train d'écrire… », accusés de lecture ;
  • trading et enchères en direct.

Ce qu'il faut coder soi-même

Le revers de la médaille : WebSocket est un protocole à part, et l'API du navigateur est très bas niveau. Elle vous donne une connexion, et c'est tout. Si cette connexion tombe, rien ne se passe. Pour une app en production, vous devez gérer vous-même :

  • la reconnexion, avec un délai qui augmente à chaque échec (backoff) et un peu d'aléatoire pour éviter que tous les clients se reconnectent à la même seconde après une panne ;
  • le heartbeat. Une connexion TCP peut mourir sans que personne ne le remarque (réseau mobile qui change, box qui redémarre, proxy qui coupe les connexions inactives). Le navigateur n'expose pas les ping/pong du protocole : il faut envoyer vos propres messages de ping et surveiller qu'on reçoit bien quelque chose ;
  • la resynchronisation. Les messages envoyés pendant la coupure sont perdus. À la reconnexion, il faut redemander l'état courant au serveur.

Voici à quoi ressemble une version minimale de tout ça :

javascript
function createSocket(url, onMessage) {
  let socket;
  let attempt = 0;
  let lastMessageAt = Date.now();
  let heartbeat;
  let stopped = false;

  function open() {
    socket = new WebSocket(url);

    socket.onopen = () => {
      attempt = 0;
      lastMessageAt = Date.now();
      // Redemander l'état courant : les messages pendant la coupure sont perdus
      socket.send(JSON.stringify({ type: "resync" }));

      heartbeat = setInterval(() => {
        // Plus rien reçu depuis 35 s : la connexion est probablement morte
        if (Date.now() - lastMessageAt > 35_000) return restart();
        socket.send(JSON.stringify({ type: "ping" })); // le serveur répond "pong"
      }, 15_000);
    };

    socket.onmessage = (event) => {
      lastMessageAt = Date.now();
      const message = JSON.parse(event.data);
      if (message.type !== "pong") onMessage(message);
    };

    socket.onclose = () => {
      clearInterval(heartbeat);
      if (stopped) return;
      const delay = Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000;
      attempt += 1;
      setTimeout(open, delay);
    };
  }

  function restart() {
    clearInterval(heartbeat);
    socket.onclose = null; // on ne compte pas sur l'événement close d'une connexion morte
    socket.close();
    open();
  }

  open();

  return {
    send: (message) => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify(message)),
    restart,
    close: () => {
      stopped = true;
      socket.close();
    },
  };
}

Une soixantaine de lignes, sans gestion de file d'attente pour les messages envoyés pendant la coupure, et il faut encore que le serveur sache répondre aux ping et aux resync. C'est pour ça que beaucoup d'équipes passent par une bibliothèque comme Socket.IO, qui ajoute aussi son propre protocole par-dessus. Ce n'est pas un problème, mais il faut le savoir au moment de choisir : avec SSE, tout ça est déjà dans le navigateur.

Le piège du mobile : arrière-plan et premier plan

C'est le point qui m'a le plus marqué en production, et celui qu'on voit rarement dans les comparatifs.

Ce qui se passe quand l'utilisateur quitte votre page

Sur un téléphone, votre page passe son temps à être mise de côté. L'utilisateur change d'app, répond à un message, verrouille son écran. Pour économiser la batterie, le système suspend alors l'onglet, et les connexions réseau finissent par être coupées. C'est particulièrement agressif sur iOS, où tous les navigateurs utilisent le moteur de Safari.

Ce n'est un problème ni pour SSE ni pour WebSocket en soi : dans les deux cas, la connexion tombe. La différence, c'est ce qui se passe quand l'utilisateur revient.

Mon retour de terrain

Avec les WebSockets, sur Safari mobile, j'ai constaté qu'après plusieurs passages arrière-plan / premier plan, la reconnexion ne se faisait parfois tout simplement plus. Comme s'il y avait une limite quelque part. La page revient, l'interface semble normale, mais plus aucune mise à jour n'arrive.

Avec SSE, dans les mêmes conditions, je n'ai jamais reproduit le problème. Au retour au premier plan, le navigateur relance la connexion de lui-même, l'en-tête Last-Event-ID permet de rattraper les événements manqués, et readyState reflète fidèlement l'état réel.

Je ne prétends pas avoir identifié la cause exacte côté WebSocket. Mais la logique est assez claire : avec SSE, la reconnexion est gérée par le navigateur, qui sait quand il suspend et réveille une page. Avec WebSocket, elle repose sur votre code, qui ne s'exécute plus pendant la suspension et qui se réveille dans un état incertain : timers en retard, événement close arrivé tard ou jamais, connexion qui semble ouverte mais ne transmet plus rien.

[PLACEHOLDER : retour d'expérience projet SSE + lien vers l'étude de cas]

Si vous restez sur WebSocket

Si votre usage impose WebSocket, ne vous fiez pas aux événements de la connexion au retour au premier plan. Écoutez la visibilité de la page, et forcez une vérification, voire une reconnexion, dès qu'elle redevient visible :

javascript
const connection = createSocket("wss://example.com/live", handleMessage);
let hiddenAt = 0;

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    hiddenAt = Date.now();
    return;
  }
  // Après plus de quelques secondes en arrière-plan, on repart sur une connexion neuve
  if (Date.now() - hiddenAt > 5_000) connection.restart();
});

Et testez sur de vrais appareils : un iPhone, plusieurs allers-retours entre les apps, l'écran verrouillé quelques minutes. Les simulateurs et les outils de développement desktop ne reproduisent pas ce comportement.

Les limites de SSE

SSE n'est pas parfait non plus. Avant de choisir, voici ce qu'il faut savoir.

  • Texte uniquement. Pas de binaire. Pour du JSON, ce n'est pas un problème ; pour de l'audio ou des données compactes, il faudra encoder en base64, ou passer à WebSocket.
  • 6 connexions par domaine en HTTP/1.1. Les navigateurs limitent le nombre de connexions simultanées vers un même domaine, tous onglets confondus. Un utilisateur qui ouvre votre app dans 7 onglets bloque le 7e. Avec HTTP/2 ou HTTP/3, les flux partagent une seule connexion et le problème disparaît. Vérifiez que votre serveur ou votre CDN sert bien en HTTP/2.
  • Pas d'en-têtes personnalisés. L'EventSource natif ne permet pas d'ajouter un Authorization: Bearer …. Les options : une authentification par cookie (avec withCredentials: true si l'API est sur un autre domaine), un jeton court dans l'URL, ou fetch avec un ReadableStream. Cette dernière option vous redonne le contrôle des en-têtes, mais vous fait perdre la reconnexion automatique, sauf à passer par une bibliothèque comme @microsoft/fetch-event-source.
  • Le buffering des intermédiaires. Certains proxys et CDN accumulent la réponse avant de la transmettre, ce qui casse le temps réel. Sur nginx, désactivez-le avec proxy_buffering off ou l'en-tête X-Accel-Buffering: no, et méfiez-vous de la compression, qui peut produire le même effet.
  • Les timeouts d'inactivité. Une connexion silencieuse trop longtemps peut être coupée par un load balancer. Envoyez un commentaire : ping toutes les 15 à 30 secondes.
  • Une connexion ouverte par client. Comme pour WebSocket, votre serveur doit tenir des milliers de connexions longues. Les runtimes asynchrones (Node.js, Go, Elixir, Rust…) le font très bien ; un serveur qui consacre un thread par requête beaucoup moins. Et certaines plateformes serverless limitent la durée des réponses.

Tableau récapitulatif

PollingLong pollingSSEWebSocketWebTransport
DirectionClient → serveur, puis réponseServeur → client, une requête par messageServeur → clientBidirectionnelleBidirectionnelle, plusieurs flux
LatenceMoitié de l'intervalleFaibleFaibleTrès faibleTrès faible
Fréquence adaptéeRareFaible à moyenneMoyenne à élevée, côté serveurTrès élevée, dans les deux sensTrès élevée, y compris tolérante aux pertes
BinaireOuiOuiNon, texte uniquementOuiOui
Reconnexion automatiqueSans objetÀ coderOui, nativeÀ coderÀ coder
Reprise des messages manquésSans objetÀ coderOui, via Last-Event-IDÀ coderÀ coder
Mobile (arrière-plan)RobusteRobusteRobusteFragile, à surveillerEncore peu éprouvé
InfrastructureHTTP classiqueHTTP classiqueHTTP classique, attention au bufferingUpgrade à supporter par les proxys et load balancersHTTP/3 et UDP requis
Support navigateurUniverselUniverselUniverselUniverselTous les majeurs depuis 2026, implémentations inégales
ComplexitéTrès faibleMoyenneFaibleMoyenne à élevéeÉlevée

Conclusion : quelle solution choisir ?

Pour mettre à jour une page ou une app en temps réel, posez-vous les questions dans cet ordre :

  1. Le client écoute surtout, et agit de temps en temps ? → SSE, avec des requêtes POST classiques pour les actions. C'est le cas de la plupart des apps : notifications, statuts, dashboards, streaming d'IA. Vous obtenez la reconnexion, la reprise des messages et la robustesse sur mobile sans rien coder.
  2. Client et serveur dialoguent vite, dans les deux sens, avec beaucoup de petits messages ? → WebSocket. Jeu vidéo, multijoueur, édition collaborative. Prévoyez la reconnexion, le heartbeat et la resynchronisation, ou une bibliothèque qui le fait, et testez sur mobile.
  3. Les mises à jour sont rares, ou l'infrastructure ne supporte pas les connexions longues ? → polling, ou long polling. Moins élégant, mais simple et ça passe partout.
  4. Latence extrême, données qui tolèrent la perte, public maîtrisé ? → WebTransport, avec une solution de repli. C'est l'avenir pour le jeu et la vidéo temps réel, mais en 2026, c'est encore un choix de spécialiste.

Si vous hésitez entre SSE et WebSocket, commencez par SSE. Si vous découvrez plus tard un vrai besoin de dialogue rapide, la migration est simple : vos événements existent déjà, seul le transport change. L'inverse est rarement vrai : une fois qu'on a écrit toute la plomberie de reconnexion d'une WebSocket, on ne la jette pas facilement.

La prochaine étape

Et si on donnait forme
à votre idée ?

30 minutes pour parler de votre projet, de vos enjeux et de la suite.

Parlons de votre projet