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
ChampRôle
dataContenu de l’événement ; plusieurs lignes data sont concaténées avec un saut de ligne
eventType de l’événement ; absent, il vaut message
idIdentifiant mémorisé par le navigateur, renvoyé à la reconnexion
retryDélai de reconnexion souhaité, en millisecondes
: en début de ligneCommentaire, 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èrePollingSSEWebSocket
SensClient → serveurServeur → clientBidirectionnel
LatenceIntervalle d’interrogationImmédiateImmédiate
Reconnexion et repriseSans objetNatives (Last-Event-ID)À programmer
InfrastructureHTTP classiqueHTTP classique, sans tamponMise à niveau du protocole, proxys compatibles
DonnéesTout formatTexte UTF-8Texte 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 : ping toutes 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é : EventSource ne permet pas d’ajouter Authorization. L’authentification passe par un cookie (avec withCredentials: true en cross-origin), ce que Mercure exploite avec un JWT en cookie.
🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.