Pourquoi s’intéresser aux Server-Sent Events ?

Quand on parle de temps réel sur le web, on pense souvent immédiatement aux WebSockets. Pourtant, beaucoup d’interfaces n’ont pas besoin d’une communication bidirectionnelle complète. Dans un tableau de bord, un suivi de génération IA, un flux de notifications, une progression d’import CSV ou une page d’administration, le besoin principal est souvent plus simple : le serveur doit pousser des informations vers le navigateur.

C’est précisément le rôle des Server-Sent Events, souvent abrégés SSE. Contrairement à une requête HTTP classique, la connexion reste ouverte. Le serveur écrit progressivement des messages dans la réponse, et le navigateur les reçoit au fil de l’eau via l’interface EventSource.

Cette approche complète très bien d’autres techniques déjà abordées dans les articles sur la Web Streams API, AbortController ou encore le cache frontend stale-while-revalidate. Les SSE ne remplacent pas tout, mais ils constituent une solution particulièrement élégante pour un flux descendant serveur vers client.

SSE, WebSocket, polling : choisir le bon outil

Avant d’écrire du code, il faut clarifier le besoin.

Le polling consiste à interroger régulièrement une API : toutes les 2 secondes, toutes les 5 secondes, etc. C’est simple, mais souvent inefficace. Le navigateur envoie beaucoup de requêtes inutiles quand rien ne change, et l’interface peut rester légèrement en retard.

Les WebSockets créent un canal bidirectionnel persistant. C’est très puissant pour un chat, un jeu multijoueur, un outil collaboratif ou une application de trading. Mais cette puissance a un coût : protocole spécifique, gestion plus fine des connexions, infrastructure parfois plus complexe derrière un reverse proxy ou un load balancer.

Les Server-Sent Events se placent entre les deux. Ils utilisent HTTP, sont nativement pris en charge par le navigateur avec EventSource, gèrent automatiquement la reconnexion, et conviennent très bien aux flux unidirectionnels.

Quelques cas d’usage typiques :

  • afficher la progression d’un traitement long ;
  • recevoir des notifications serveur ;
  • suivre les logs d’un job en cours ;
  • mettre à jour un dashboard ;
  • streamer les étapes d’une réponse générée côté serveur ;
  • synchroniser un état essentiellement lu côté client.

Si le client doit aussi envoyer beaucoup de messages au serveur en temps réel, WebSocket sera probablement plus adapté. Si le client envoie seulement une action ponctuelle, puis écoute le résultat, SSE est souvent suffisant.

Créer un endpoint SSE avec Node.js

Un flux SSE est une réponse HTTP avec le type MIME text/event-stream. Le serveur garde la connexion ouverte et écrit des blocs de texte au format attendu par le navigateur.

Voici un exemple minimal avec Node.js natif :

import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url !== "/events") {
    res.writeHead(404);
    res.end("Not found");
    return;
  }

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    "Connection": "keep-alive",
    "Access-Control-Allow-Origin": "*"
  });

  res.write(`event: ready\n`);
  res.write(`data: {"message":"Connexion ouverte"}\n\n`);

  let count = 0;

  const interval = setInterval(() => {
    count += 1;

    const payload = JSON.stringify({
      count,
      createdAt: new Date().toISOString()
    });

    res.write(`event: tick\n`);
    res.write(`data: ${payload}\n\n`);
  }, 1000);

  req.on("close", () => {
    clearInterval(interval);
  });
});

server.listen(3000, () => {
  console.log("SSE server listening on http://localhost:3000/events");
});

Le détail le plus important est le double saut de ligne \n\n, qui indique la fin d’un message. Chaque message peut contenir plusieurs champs, dont les plus fréquents sont event, data, id et retry.

Le champ event permet de nommer le type d’événement. Le champ data contient le contenu envoyé au client. Comme il s’agit de texte, on sérialise généralement les données en JSON.

Consommer le flux côté navigateur

Côté client, l’API est très concise :

const source = new EventSource("http://localhost:3000/events");

source.addEventListener("ready", (event) => {
  const data = JSON.parse(event.data);
  console.log("Prêt :", data.message);
});

source.addEventListener("tick", (event) => {
  const data = JSON.parse(event.data);
  console.log("Tick reçu :", data.count, data.createdAt);
});

source.onerror = () => {
  console.log("Connexion perdue ou en cours de reconnexion");
};

EventSource tente automatiquement de se reconnecter si la connexion est interrompue. C’est l’un des avantages majeurs de SSE par rapport à une gestion manuelle avec fetch ou une boucle de polling.

Pour fermer explicitement la connexion côté client :

source.close();

Cette fermeture est essentielle dans une application frontend moderne. Dans un composant React, Vue ou Svelte, il faut libérer la connexion lorsque le composant disparaît, comme on le ferait avec un setInterval, un abonnement ou un effet asynchrone. Cette logique rejoint les bonnes pratiques décrites dans l’article sur AbortController en JavaScript, même si EventSource ne repose pas directement sur un AbortSignal.

Exemple avec React : suivre une progression

Prenons un cas courant : un traitement serveur long, par exemple l’analyse d’un fichier, l’indexation de documents ou la génération d’un rapport. Le navigateur doit afficher une progression sans relancer constamment une requête.

import { useEffect, useState } from "react";

type ProgressEvent = {
  step: string;
  progress: number;
};

export function ImportProgress({ jobId }: { jobId: string }) {
  const [progress, setProgress] = useState(0);
  const [step, setStep] = useState("Initialisation");

  useEffect(() => {
    const source = new EventSource(`/api/jobs/${jobId}/events`);

    source.addEventListener("progress", (event) => {
      const data = JSON.parse(event.data) as ProgressEvent;
      setProgress(data.progress);
      setStep(data.step);
    });

    source.addEventListener("done", () => {
      setProgress(100);
      setStep("Terminé");
      source.close();
    });

    source.onerror = () => {
      console.warn("Connexion SSE interrompue");
    };

    return () => {
      source.close();
    };
  }, [jobId]);

  return (
    <section aria-live="polite">
      <p>{step}</p>
      <progress value={progress} max={100} />
      <span>{progress}%</span>
    </section>
  );
}

Le [aria-live](/articles/aria-live-regions-accessibilite)="polite" permet aux technologies d’assistance d’être informées des changements sans interrompre brutalement la navigation. Dès qu’un flux temps réel modifie l’interface, l’accessibilité doit être prise en compte. C’est particulièrement vrai pour les notifications, les statuts de tâche ou les erreurs. Pour approfondir cet aspect, vous pouvez relire l’article sur les formulaires accessibles, qui aborde des principes proches autour des messages d’état et de validation.

Structurer les messages SSE

Un message SSE peut contenir plusieurs informations utiles :

event: progress
id: job-42-step-3
retry: 3000
data: {"step":"Optimisation","progress":60}

Le champ id est particulièrement intéressant. Quand la connexion est interrompue, le navigateur peut envoyer l’en-tête Last-Event-ID lors de la reconnexion. Le serveur peut alors reprendre le flux à partir du dernier événement connu, si votre application conserve un historique.

Un exemple de gestion côté serveur :

const lastEventId = req.headers["last-event-id"];

if (lastEventId) {
  console.log("Reprise après l’événement", lastEventId);
}

Ce mécanisme ne dispense pas de concevoir une stratégie de persistance. Si votre serveur envoie uniquement des événements volatils en mémoire, il ne pourra pas reconstruire ce qui a été manqué. Pour des notifications critiques, stockez les événements en base de données ou exposez une API de rattrapage.

Points d’attention en production

Les SSE sont simples, mais quelques détails peuvent provoquer des bugs discrets.

D’abord, certains reverse proxies tamponnent les réponses HTTP. Si le proxy attend d’avoir beaucoup de données avant de les transmettre, le client ne recevra plus les événements immédiatement. Avec Nginx, on désactive souvent le buffering pour ces routes.

location /events {
  proxy_pass http://app:3000;
  proxy_buffering off;
  proxy_cache off;
}

Ensuite, une connexion SSE reste ouverte. Cela signifie qu’elle consomme une connexion côté serveur. Pour un petit nombre d’utilisateurs, ce n’est généralement pas problématique. Pour un dashboard ouvert par des milliers de clients, il faut surveiller les limites de connexions, la mémoire et le comportement du runtime.

Il est aussi conseillé d’envoyer régulièrement un commentaire SSE pour garder la connexion active :

const heartbeat = setInterval(() => {
  res.write(`: heartbeat\n\n`);
}, 15000);

req.on("close", () => {
  clearInterval(heartbeat);
});

Une ligne qui commence par : est un commentaire. Elle est ignorée par EventSource, mais elle peut empêcher certains intermédiaires réseau de fermer une connexion jugée inactive.

Authentification et sécurité

EventSource effectue une requête HTTP classique, mais son API est plus limitée que fetch. On ne peut pas facilement ajouter des headers personnalisés comme Authorization: Bearer ... avec l’implémentation native.

Plusieurs stratégies existent :

  • utiliser des cookies HTTP-only avec une session serveur ;
  • générer une URL signée à durée courte ;
  • passer un identifiant opaque en paramètre d’URL, en évitant d’y placer un secret long terme ;
  • vérifier systématiquement les droits côté serveur avant d’ouvrir le flux.

Avec une authentification par cookie, l’ouverture ressemble à ceci :

const source = new EventSource("/api/notifications", {
  withCredentials: true
});

Côté serveur, il faut aussi penser au CORS si le frontend et l’API ne partagent pas la même origine. Dans ce cas, évitez Access-Control-Allow-Origin: * avec des credentials. Déclarez explicitement l’origine autorisée.

Quand éviter les Server-Sent Events ?

Les SSE ne sont pas universels. Évitez-les si vous avez besoin d’un échange bidirectionnel intensif, de très faibles latences dans les deux sens, ou d’un protocole applicatif complexe entre client et serveur. Dans ces situations, WebSocket ou WebTransport peuvent être plus appropriés.

Évitez aussi d’utiliser SSE pour contourner une mauvaise architecture de données. Si votre interface peut être correctement servie par du cache HTTP, une stratégie de revalidation ou un rendu serveur, un flux persistant sera parfois excessif. L’article sur React Server Components montre par exemple comment réduire le JavaScript envoyé au navigateur sans ajouter nécessairement de canal temps réel.

Enfin, ne confondez pas SSE et streaming arbitraire. Si vous devez lire un corps de réponse très personnalisé avec transformation progressive côté client, la Web Streams API peut être plus flexible. SSE brille surtout quand vous voulez un protocole simple d’événements serveur.

Une API discrète, mais très efficace

Les Server-Sent Events sont une excellente option pour de nombreuses interfaces temps réel : plus réactives que le polling, plus simples que WebSocket, et compatibles avec l’infrastructure HTTP classique. Leur force vient de cette sobriété.

Pour les utiliser correctement, retenez trois règles : fermez toujours les connexions inutiles, prévoyez la reconnexion et surveillez les intermédiaires réseau comme les proxies. Avec ces précautions, SSE devient un outil très fiable pour diffuser des notifications, des progressions, des logs ou des mises à jour de dashboard.

Dans une architecture frontend moderne, ce n’est pas l’outil le plus spectaculaire, mais c’est souvent l’un des plus pragmatiques.