Comprendre · Temps réel · HTTP
Server-Sent Events : le temps réel simple sur HTTP
Un flux HTTP qui ne se ferme pas : format text/event-stream, API EventSource, reconnexion automatique avec Last-Event-ID, et quand préférer SSE à WebSocket ou au polling.
Le besoin : informer sans être interrogé
Un assuré suit son dossier de sinistre en ligne : « Déclaré », « En expertise », « Indemnisé ». Il veut voir le statut changer sans rafraîchir la page, et le gestionnaire veut que son tableau de bord se mette à jour quand un collègue traite un dossier. Fil rouge de cet article et des suivants : l’espace client fictif d’Acme Assurances, sur acme-assurances.example.
HTTP est conçu pour que le client demande et que le serveur réponde. Pour que le serveur prenne l’initiative, trois familles de solutions existent : interroger régulièrement (polling), ouvrir un canal bidirectionnel (WebSocket), ou garder ouverte une réponse HTTP dans laquelle le serveur écrit au fil de l’eau : les Server-Sent Events (SSE), normalisés dans la spécification HTML.
Le format text/event-stream
Une réponse SSE est une réponse HTTP ordinaire, de type text/event-stream, qui ne se termine pas. Elle contient des événements en texte UTF-8, séparés par une ligne vide, chacun composé de champs nom: valeur :
: commentaire, ignoré par le client (sert de battement de cœur)
retry: 5000
id: 1842
event: sinistre.statut
data: {"id":"SIN-0042","statut":"En expertise"}
id: 1843
data: Une ligne de texte
data: sur deux lignes | Champ | Rôle |
|---|---|
data | Contenu de l’événement ; plusieurs lignes data sont concaténées avec un saut de ligne |
event | Type de l’événement ; absent, il vaut message |
id | Identifiant mémorisé par le navigateur, renvoyé à la reconnexion |
retry | Délai de reconnexion souhaité, en millisecondes |
: en début de ligne | Commentaire, ignoré : idéal pour un battement de cœur |
Pas de binaire, pas d’en-tête de trame : un flux se lit avec curl -N et se débogue dans l’onglet réseau du navigateur. Pour transporter un objet, on sérialise en JSON dans data.
Reconnexion et Last-Event-ID
C’est l’atout le plus sous-estimé de SSE. Si la connexion tombe (Wi-Fi, mise en veille, redémarrage du serveur), le navigateur se reconnecte seul après le délai retry, et joint l’identifiant du dernier événement reçu :
GET /sinistres/SIN-0042/flux HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1843 Le serveur reprend alors à partir de cet identifiant : aucun changement de statut n’est perdu pendant la coupure, à condition de conserver un historique (une table d’événements, un journal) et d’attribuer des identifiants croissants ou au moins retrouvables. Avec WebSocket, cette logique est entièrement à écrire.
Pour arrêter définitivement les tentatives, le serveur répond autre chose que 200 en text/event-stream, par exemple 204 No Content.
Côté serveur : un flux en PHP
En Symfony, une StreamedResponse suffit pour comprendre le mécanisme. Depuis Symfony 7.3, EventStreamResponse et ServerEvent se chargent en plus du formatage des champs.
<?php
namespace App\Controller;
use App\Sinistre\SinistreEvents;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
final class SinistreFluxController
{
public function __construct(private readonly SinistreEvents $events)
{
}
#[Route('/sinistres/{id}/flux', methods: ['GET'])]
public function __invoke(string $id, Request $request): StreamedResponse
{
// Reprise : le navigateur renvoie le dernier identifiant reçu.
$lastId = (int) $request->headers->get('Last-Event-ID', '0');
$response = new StreamedResponse(function () use ($id, $lastId): void {
$deadline = time() + 300; // borne la durée : le client se reconnectera
while (time() < $deadline && !connection_aborted()) {
foreach ($this->events->after($id, $lastId) as $event) {
echo "id: {$event->id}\n";
echo "event: sinistre.statut\n";
echo 'data: ' . json_encode($event->payload) . "\n\n";
$lastId = $event->id;
}
echo ": ping\n\n"; // garde la connexion vivante à travers les proxys
flush();
sleep(2);
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Accel-Buffering', 'no'); // nginx : ne pas mettre le flux en tampon
return $response;
}
} Ce contrôleur fonctionne, mais il révèle le problème de fond : chaque abonné occupe un worker PHP pendant toute la durée de sa connexion. Avec un pool PHP-FPM de 50 workers, le 51e assuré qui ouvre son dossier bloque tout le site. Pour quelques administrateurs, c’est acceptable ; pour un espace client, il faut sortir les connexions de PHP. C’est exactement le rôle d’un hub Mercure.
SSE, WebSocket ou polling ?
| Critère | Polling | SSE | WebSocket |
|---|---|---|---|
| Sens | Client → serveur | Serveur → client | Bidirectionnel |
| Latence | Intervalle d’interrogation | Immédiate | Immédiate |
| Reconnexion et reprise | Sans objet | Natives (Last-Event-ID) | À programmer |
| Infrastructure | HTTP classique | HTTP classique, sans tampon | Mise à niveau du protocole, proxys compatibles |
| Données | Tout format | Texte UTF-8 | Texte ou binaire |
La plupart des besoins « temps réel » d’une application de gestion sont unidirectionnels : notifications, statuts, tableaux de bord, progression d’un traitement. Les actions de l’utilisateur passent très bien par des requêtes HTTP ordinaires. WebSocket se justifie pour les échanges denses dans les deux sens (édition collaborative fine, jeu, terminal distant). Le polling reste pertinent quand une fraîcheur d’une minute suffit.
Les pièges : proxys, HTTP/1.1, authentification
- Mise en tampon : un proxy ou une compression qui accumule la réponse retarde les événements jusqu’à plusieurs kilo-octets. Désactiver tampon et gzip sur la route du flux.
- Délais d’inactivité : les proxys et répartiteurs de charge coupent une connexion muette après 30 à 60 secondes. Un commentaire
: pingtoutes les 15 à 30 secondes l’en empêche. - Six connexions en HTTP/1.1 : un navigateur limite à six connexions simultanées par domaine, onglets confondus ; un flux ouvert par onglet épuise vite ce quota. En HTTP/2 ou HTTP/3, les flux sont multiplexés sur une seule connexion et la limite disparaît en pratique.
- Pas d’en-tête personnalisé :
EventSourcene permet pas d’ajouterAuthorization. L’authentification passe par un cookie (avecwithCredentials: trueen cross-origin), ce que Mercure exploite avec un JWT en cookie.