← Tous les cours / Streaming HTTP — Réponses en flux continu Cours
00 — VUE D'ENSEMBLE

Requête classique ou flux continu ?

HTTP peut répondre en un seul bloc — ou envoyer les données au fil de l'eau. Découvre quand et pourquoi streamer.

Dans le web classique, une requête HTTP suit un schéma simple : tu envoies une demande, le serveur traite tout, puis répond d'un seul bloc. C'est parfait pour une page HTML ou un petit JSON. Mais que se passe-t-il quand la réponse prend du temps à se construire, ou qu'elle est volumineuse ?

Le streaming HTTP casse ce modèle. Au lieu d'attendre que tout soit prêt, le serveur envoie les données au fur et à mesure, par morceaux. Le client reçoit et affiche pendant que le serveur produit encore.

Streaming HTTP
Technique qui consiste à envoyer une réponse HTTP en plusieurs fragments plutôt qu'en un seul bloc. Le client commence à traiter les premières données avant que les dernières soient produites côté serveur.
Client
envoie GET
Serveur
produit en continu
→ chunks →
Client
reçoit au fil de l'eau

Trois mécanismes couvrent la quasi-totalité des besoins :

Mécanisme Direction Usage typique
Transfer-Encoding: chunked Serveur → Client Réponse longue à calculer, export progressif
SSE Server-Sent Events Serveur → Client (push continu) Notifications, logs en direct, tokens d'un LLM
readfile() / BinaryFileResponse Serveur → Client Téléchargement de fichiers volumineux (PDF, archives)
💡 HTTP/2 et streaming : HTTP/2 remplace le chunked encoding par son propre système de frames binaires, mais du point de vue PHP et Symfony, l'API reste identique — le serveur (FrankenPHP, Nginx) gère la traduction automatiquement.
01 — CHUNKED TRANSFER

Chunked Transfer Encoding

Le mécanisme HTTP/1.1 qui permet d'envoyer une réponse sans connaître sa taille à l'avance.

Imagine que tu veux livrer une pizza, mais la boulangerie la prépare tranche par tranche. Au lieu d'attendre que tout soit prêt, tu livres chaque tranche dès qu'elle sort du four. Le Chunked Transfer Encoding fonctionne exactement ainsi.

Transfer-Encoding: chunked
Extension HTTP/1.1 (RFC 7230) qui découpe le corps de la réponse en fragments de taille variable. Chaque fragment (chunk) est précédé de sa taille en hexadécimal. Un chunk de taille 0 signale la fin du flux. Remplace Content-Length quand la taille totale est inconnue.

Structure d'une réponse chunked sur le fil :

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain

1a                 ← taille du chunk en hex (= 26 octets)
abcdefghijklmnopqrstuvwxyz
0d                 ← 13 octets
Hello, World!
0                  ← fin du flux
🔑 Sans Content-Length : quand le serveur streame, il ne connaît pas encore la taille totale. Transfer-Encoding: chunked et Content-Length ne peuvent pas coexister dans la même réponse.

En PHP, activer le streaming est une question de buffers. Par défaut, PHP accumule la sortie avant de l'envoyer — il faut désactiver ce comportement :

// Désactiver la mise en tampon PHP
ob_implicit_flush(true);
if (ob_get_level() > 0) {
    ob_end_flush();
}

header('Content-Type: text/plain; charset=utf-8');
header('X-Accel-Buffering: no'); // désactive le buffer Nginx/FrankenPHP

for ($i = 1; $i <= 10; $i++) {
    echo "Étape $i traitée\n";
    flush();     // envoie immédiatement au client
    sleep(1);    // simule un traitement long
}

Avec Symfony, utilise StreamedResponse pour garder l'architecture propre :

use Symfony\Component\HttpFoundation\StreamedResponse;

return new StreamedResponse(function() {
    for ($i = 1; $i <= 10; $i++) {
        echo "data: {\"step\":$i}\n\n";
        ob_flush();
        flush();
        sleep(1);
    }
}, 200, [
    'Content-Type'  => 'text/plain',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);
Sans streaming

Le client attend que les 10 étapes soient terminées. Dix secondes de page blanche, puis tout apparaît.

Latence perçue = durée totale
Avec streaming

Le client voit chaque étape apparaître au fil de l'eau. L'interface reste vivante.

Premier octet immédiat
02 — SERVER-SENT EVENTS

Server-Sent Events — push du serveur vers le client

Un protocole dédié aux notifications en temps réel : notifications, logs live, tokens de LLM.

Le Chunked Encoding convient aux réponses longues à construire. Mais pour pousser des événements en continu — mises à jour, notifications, tokens d'un LLM — il existe un protocole dédié et standardisé : les Server-Sent Events.

Server-Sent Events (SSE)
Protocole HTTP uni-directionnel (serveur → client) défini par le W3C. Le serveur ouvre une connexion HTTP persistante avec le Content-Type: text/event-stream et envoie des événements textuels. Le navigateur les reçoit via l'API EventSource. La reconnexion automatique est intégrée.

Format d'un message SSE — texte brut, champs séparés par \n, message terminé par \n\n :

# Message simple
data: {"type":"metric","value":42}

# Message nommé avec ID (pour la reprise)
event: notification
data: Nouvelle commande #1024
id: 1024

Côté serveur PHP :

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');

$id = 0;
while (true) {
    $payload = json_encode(['id' => ++$id, 'v' => rand(0, 100)]);
    echo "id: $id\ndata: $payload\n\n";
    flush();
    sleep(1);
    if (connection_aborted()) break; // client déconnecté
}

Côté client JavaScript :

const source = new EventSource('/api/events');

source.onmessage = (e) => {
    const data = JSON.parse(e.data);
    console.log(`Reçu id=${data.id} v=${data.v}`);
};

// Écouter un event nommé
source.addEventListener('notification', (e) => {
    alert(e.data);
});

source.onerror = () => {
    // EventSource se reconnecte automatiquement
    console.warn('SSE: reconnexion...');
};
💡 Reconnexion automatique : EventSource se reconnecte si la connexion est perdue. Le navigateur envoie l'en-tête Last-Event-ID avec le dernier ID reçu — utilise-le côté serveur pour reprendre sans perte.
⚠️ Limite HTTP/1.1 : les navigateurs autorisent 6 connexions SSE par domaine en HTTP/1.1. Avec HTTP/2 (multiplexage), cette limite disparaît. FrankenPHP supporte HTTP/2 nativement.

Clique sur Démarrer pour simuler un flux SSE — les événements arrivent au fil de l'eau, comme un vrai stream.

En attente
03 — FICHIERS & PDF

Streaming de fichiers et PDF

Télécharger un fichier volumineux sans saturer la RAM — readfile(), BinaryFileResponse et StreamedResponse.

Tu as une facture de 50 Mo à envoyer. L'erreur classique : charger l'intégralité du fichier en mémoire PHP, puis l'envoyer. Sur un serveur avec 128 Mo de RAM et dix téléchargements simultanés, c'est le plantage assuré.

Streaming de fichier
Lire et envoyer un fichier par petits blocs sans jamais l'avoir entièrement en mémoire. PHP maintient une empreinte mémoire constante (quelques Ko) quelle que soit la taille du fichier.

❌ Anti-pattern

// Charge TOUT le fichier en RAM
$content = file_get_contents($path);
echo $content;

50 Mo de RAM par requête. Dix requêtes = 500 Mo. Mort.

✓ Streaming

// Lit et envoie par blocs de 8 Ko
readfile($path);

Mémoire constante ≈ 8 Ko quelle que soit la taille.

En PHP natif, pour forcer le téléchargement d'un PDF avec les bons en-têtes :

$path = '/var/storage/invoices/2024-001.pdf';

if (!is_file($path)) {
    http_response_code(404);
    exit;
}

header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="facture-2024-001.pdf"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, no-store');

readfile($path);

Avec Symfony, utilise les classes dédiées — elles gèrent automatiquement les en-têtes, les requêtes partielles (Range) et le streaming :

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;

// Fichier sur disque — la solution la plus simple
return new BinaryFileResponse(
    $path,
    200,
    [],
    true, // X-Sendfile si dispo
    ResponseHeaderBag::DISPOSITION_ATTACHMENT,
);

// Contenu généré à la volée (ex: export CSV, PDF généré par DomPDF)
return new StreamedResponse(function () use ($generator) {
    foreach ($generator->rows() as $row) {
        fputcsv(STDOUT, $row);
        ob_flush();
        flush();
    }
}, 200, [
    'Content-Type'        => 'text/csv; charset=utf-8',
    'Content-Disposition' => 'attachment; filename="export.csv"',
]);
💡 PDF inline vs téléchargement : Content-Disposition: inline ouvre le PDF dans le navigateur. attachment force le téléchargement. Les deux se gèrent via ResponseHeaderBag::DISPOSITION_INLINE et DISPOSITION_ATTACHMENT.
⚠️ Sécurité : ne jamais exposer directement un $path venant d'un paramètre HTTP. Valide que le fichier est bien dans un répertoire autorisé avec realpath() avant de le servir.
04 — SYNTHÈSE

Synthèse — choisir la bonne approche

Chunked, SSE, BinaryFileResponse, StreamedResponse : guide de décision pour chaque cas d'usage.

Trois mécanismes, trois cas d'usage. Le tableau ci-dessous est ton guide de décision.

Besoin Solution Pourquoi
Réponse longue à calculer (export, rapport) StreamedResponse + chunked Le client voit la progression, pas de timeout
Notifications en temps réel, logs live, tokens LLM Server-Sent Events (text/event-stream) Protocole dédié, reconnexion auto, API simple
Téléchargement de fichier existant sur disque BinaryFileResponse Gère Range, X-Sendfile, disposition automatiquement
Génération de fichier à la volée (CSV, PDF) StreamedResponse Mémoire constante, envoi progressif
Communication bi-directionnelle temps réel WebSocket (hors scope HTTP) SSE ne va que dans un sens — pour les deux sens, WebSocket
Règle d'or du streaming
Streame dès que la réponse dépasse quelques Mo, ou dès que la construction dépasse quelques secondes. L'utilisateur perçoit une interface vivante plutôt qu'une page blanche.

Rappel des points clés à retenir :

💡 FrankenPHP : supporte HTTP/2 et HTTP/3 nativement. Les limites de connexions SSE de HTTP/1.1 (6 par domaine) ne s'appliquent pas — le multiplexage HTTP/2 gère N flux SSE sur une seule connexion TCP.