WebSockets et Server-Sent Events#
HTTP est fondamentalement un protocole requête-réponse : le client initie toujours la communication, le serveur répond, et la connexion peut être fermée. Ce modèle convient parfaitement aux pages web statiques ou aux API REST, mais se révèle inadapté pour les applications nécessitant du temps réel : messagerie instantanée, tableaux de bord en direct, jeux en ligne, flux de cotations boursières, notifications push.
Ce chapitre présente les deux approches majeures pour dépasser la barrière requête-réponse de HTTP : les WebSockets pour la communication bidirectionnelle plein-duplex, et les Server-Sent Events (SSE) pour les flux unidirectionnels serveur-vers-client. Nous commençons par analyser les stratégies historiques (polling, long polling) et leurs limites.
Les limites de HTTP pour le temps réel#
Polling classique#
La stratégie la plus simple pour simuler du temps réel avec HTTP est le polling : le client envoie périodiquement des requêtes pour vérifier si de nouvelles données sont disponibles.
Client → Serveur : GET /nouvelles-donnees (t=0s)
Serveur → Client : 200 OK, rien de nouveau
Client → Serveur : GET /nouvelles-donnees (t=5s)
Serveur → Client : 200 OK, rien de nouveau
Client → Serveur : GET /nouvelles-donnees (t=10s)
Serveur → Client : 200 OK, nouvelle donnée ! {"valeur": 42}
Inconvénients majeurs :
Latence : la donnée ne peut être reçue qu’au prochain cycle de polling. Avec un intervalle de 5s, la latence moyenne est de 2,5s.
Charge serveur : des milliers de clients qui interrogent toutes les secondes génèrent un trafic énorme, même quand il n’y a rien de nouveau.
Gaspillage réseau : la majorité des réponses sont vides ou indiquent « rien de nouveau ».
Long polling#
Le long polling (ou Comet) est une amélioration : le serveur maintient la connexion ouverte jusqu’à ce qu’une nouvelle donnée soit disponible ou qu’un délai expire.
Client → Serveur : GET /evenements (maintient connexion ouverte)
... (le serveur attend qu'un événement se produise) ...
Serveur → Client : 200 OK {"message": "nouveau message"} (après 3s d'attente)
Client → Serveur : GET /evenements (immédiatement rouvert)
Avantages : latence réduite, moins de requêtes inutiles. Inconvénients :
Le serveur doit maintenir de nombreuses connexions HTTP en attente, consommant des ressources (threads, descripteurs de fichiers).
La gestion du timeout, de la reconnexion, et des erreurs est complexe.
HTTP/1.1 est conçu pour des connexions courtes ; les serveurs HTTP classiques (Apache prefork) allouent un thread par connexion.
WebSocket : protocole et handshake#
Le handshake HTTP Upgrade#
WebSocket (RFC 6455) démarre par un handshake HTTP ordinaire, puis upgraide la connexion vers le protocole WebSocket. L’avantage est que WebSocket peut passer à travers les proxys HTTP et les pare-feux qui autorisent HTTP/HTTPS sur les ports 80/443.
Requête du client :
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Extensions: permessage-deflate
Réponse du serveur :
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Le code de statut 101 Switching Protocols signale que la connexion HTTP est désormais une connexion WebSocket plein-duplex.
Calcul du Sec-WebSocket-Accept#
La validation cryptographique du handshake utilise SHA-1 :
Sec-WebSocket-Accept = Base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
La valeur 258EAFA5-... est un GUID fixé dans la RFC. Ce mécanisme évite qu’un serveur HTTP classique accepte accidentellement une connexion WebSocket.
Sec-WebSocket-Key : dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Accept : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
RFC 6455 attendu : s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Correspondance : True
Format des frames WebSocket#
Après le handshake, les données sont échangées sous forme de frames WebSocket. Le format binaire d’une frame est :
0 1 2 3
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len == 127 |
+--------------------------------+-------------------------------+
| Masking-key (si MASK=1, 4 octets) |
+--------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
Champs principaux :
FIN : 1 si c’est le dernier fragment
Opcode : type de frame (voir tableau)
MASK : 1 si les données sont masquées (obligatoire pour client→serveur)
Payload length : longueur des données
Opcodes principaux :
Opcode |
Nom |
Description |
|---|---|---|
0x0 |
Continuation |
Fragment de message |
0x1 |
Text |
Texte UTF-8 |
0x2 |
Binary |
Données binaires |
0x8 |
Close |
Fermeture de connexion |
0x9 |
Ping |
Keepalive (ping) |
0xA |
Pong |
Réponse au ping |
Masquage des frames client→serveur#
Le standard RFC 6455 impose que toutes les frames envoyées par le client soient masquées avec un masque de 4 octets aléatoires. Ce masque est inclus dans la frame et chaque octet de payload est XORé avec l’octet correspondant du masque (cycliquement).
Ce masquage protège contre les attaques par cache poisoning : un attaquant contrôlant du contenu dans le navigateur ne peut pas injecter des données WebSocket qui ressembleraient à des réponses HTTP valides, même si un proxy cache est interposé.
Le serveur ne masque jamais ses frames.
Code Python : serveur et client WebSocket#
Serveur broadcast avec la bibliothèque websockets#
import asyncio
import websockets
import json
from datetime import datetime
# Ensemble des connexions actives
CONNEXIONS = set()
async def gestionnaire(websocket):
"""Gère une connexion WebSocket individuelle."""
CONNEXIONS.add(websocket)
client_addr = websocket.remote_address
print(f"[+] Nouveau client : {client_addr} — {len(CONNEXIONS)} connecté(s)")
try:
# Envoi d'un message de bienvenue
await websocket.send(json.dumps({
"type": "bienvenue",
"message": f"Connecté au serveur. {len(CONNEXIONS)} client(s) en ligne.",
"timestamp": datetime.now().isoformat(),
}))
async for message in websocket:
# Diffusion à tous les clients connectés
data = json.loads(message)
print(f"[{client_addr}] → {data}")
# Broadcast à tous sauf l'expéditeur
reponse = json.dumps({
"type": "message",
"de": str(client_addr),
"contenu": data.get("contenu", ""),
"timestamp": datetime.now().isoformat(),
})
if CONNEXIONS:
await asyncio.gather(
*[ws.send(reponse) for ws in CONNEXIONS if ws != websocket],
return_exceptions=True,
)
except websockets.exceptions.ConnectionClosed as e:
print(f"[-] Client déconnecté : {client_addr} (code={e.code})")
finally:
CONNEXIONS.discard(websocket)
async def main():
async with websockets.serve(gestionnaire, "localhost", 8765) as server:
print("Serveur WebSocket en écoute sur ws://localhost:8765")
await server.wait_closed()
asyncio.run(main())
Client WebSocket avec ping/pong#
import asyncio
import websockets
import json
import time
async def client_chat():
"""Client WebSocket avec keepalive ping/pong automatique."""
uri = "ws://localhost:8765"
async with websockets.connect(
uri,
ping_interval=20, # Envoi d'un PING toutes les 20s
ping_timeout=10, # Timeout si PONG non reçu en 10s
close_timeout=5,
) as ws:
print(f"Connecté à {uri}")
# Réception du message de bienvenue
bienvenue = await ws.recv()
print(f"Serveur : {json.loads(bienvenue)['message']}")
# Envoi de messages et réception simultanée
async def envoyer():
for i in range(5):
msg = json.dumps({"contenu": f"Message #{i+1}"})
await ws.send(msg)
print(f"Envoyé : Message #{i+1}")
await asyncio.sleep(1)
async def recevoir():
async for message in ws:
data = json.loads(message)
if data["type"] == "message":
print(f"Reçu de {data['de']} : {data['contenu']}")
# Lancement concurrent de l'envoi et de la réception
await asyncio.gather(envoyer(), recevoir())
asyncio.run(client_chat())
Mesure de latence WebSocket#
import asyncio
import websockets
import time
import statistics
async def mesurer_latence(n_mesures=100):
"""Mesure la latence aller-retour d'une connexion WebSocket."""
async with websockets.connect("ws://localhost:8765") as ws:
latences = []
for i in range(n_mesures):
debut = time.perf_counter()
await ws.send(json.dumps({"type": "ping", "t": debut}))
reponse = await ws.recv()
fin = time.perf_counter()
latences.append((fin - debut) * 1000) # ms
print(f"Latence sur {n_mesures} messages :")
print(f" Moyenne : {statistics.mean(latences):.2f} ms")
print(f" Médiane : {statistics.median(latences):.2f} ms")
print(f" P95 : {sorted(latences)[int(0.95*n_mesures)]:.2f} ms")
print(f" Min/Max : {min(latences):.2f} / {max(latences):.2f} ms")
asyncio.run(mesurer_latence())
Server-Sent Events (SSE)#
Principe et format#
Les Server-Sent Events (W3C, maintenant partie des standards HTML Living Standard) permettent au serveur d’envoyer un flux continu d’événements à un client HTTP. Contrairement à WebSocket, la communication est unidirectionnelle (serveur → client). Le client peut envoyer des données au serveur uniquement via des requêtes HTTP séparées.
Le client ouvre une requête HTTP avec Accept: text/event-stream et le serveur envoie un flux de données tant que la connexion est ouverte :
GET /events HTTP/1.1
Host: example.com
Accept: text/event-stream
Cache-Control: no-cache
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Format du flux SSE :
: commentaire (ignoré par le client, utile comme keepalive)\n
\n
id: 1\n
event: temperature\n
data: {"capteur": "A1", "valeur": 23.4}\n
\n
id: 2\n
event: temperature\n
data: {"capteur": "A1", "valeur": 23.6}\n
\n
retry: 3000\n
\n
Chaque événement est séparé par une ligne vide. Les champs possibles sont :
data:: données de l’événement (peut s’étendre sur plusieurs lignes)event:: type de l’événement (si omis, type par défautmessage)id:: identifiant de l’événementretry:: délai de reconnexion en ms
Reconnexion automatique et Last-Event-ID#
L’un des grands avantages de SSE est sa reconnexion automatique gérée par le navigateur. Si la connexion est perdue, le navigateur attend retry millisecondes (par défaut 3000 ms) puis se reconnecte automatiquement en envoyant l’en-tête Last-Event-ID avec le dernier identifiant reçu. Le serveur peut ainsi reprendre le flux depuis le bon point.
// Côté JavaScript (navigateur)
const evtSource = new EventSource("/events");
evtSource.addEventListener("temperature", (event) => {
const data = JSON.parse(event.data);
console.log(`Température : ${data.valeur}°C (capteur ${data.capteur})`);
});
evtSource.onerror = (err) => {
console.error("Erreur SSE, reconnexion automatique dans 3s...");
};
Serveur SSE avec http.server#
import http.server
import threading
import time
import json
import random
from datetime import datetime
class SSEHandler(http.server.BaseHTTPRequestHandler):
"""Serveur SSE minimaliste avec la stdlib Python."""
def log_message(self, format, *args):
pass # Silence des logs HTTP
def do_GET(self):
if self.path == "/events":
self.send_sse_stream()
elif self.path == "/":
self.send_html_page()
else:
self.send_error(404)
def send_sse_stream(self):
"""Envoie un flux SSE de mesures de température simulées."""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
event_id = 0
try:
while True:
event_id += 1
temperature = 20 + random.gauss(0, 2)
humidite = 50 + random.gauss(0, 5)
# Format SSE : id, event, data, ligne vide
evenement = (
f"id: {event_id}\n"
f"event: mesure\n"
f"data: {json.dumps({'t': temperature, 'h': humidite, 'ts': datetime.now().isoformat()})}\n"
f"\n"
)
self.wfile.write(evenement.encode("utf-8"))
self.wfile.flush()
# Commentaire keepalive toutes les 5 secondes
if event_id % 5 == 0:
keepalive = ": keepalive\n\n"
self.wfile.write(keepalive.encode("utf-8"))
self.wfile.flush()
time.sleep(1)
except (BrokenPipeError, ConnectionResetError):
print(f"Client déconnecté après {event_id} événements")
def send_html_page(self):
"""Page HTML consommant le flux SSE."""
html = """<!DOCTYPE html>
<html><head><title>SSE Demo</title></head>
<body>
<h1>Flux SSE temps réel</h1>
<div id="data">En attente...</div>
<script>
const es = new EventSource('/events');
es.addEventListener('mesure', e => {
const d = JSON.parse(e.data);
document.getElementById('data').innerHTML =
`T: ${d.t.toFixed(1)}°C | H: ${d.h.toFixed(1)}% | ${d.ts}`;
});
</script>
</body></html>"""
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(html.encode("utf-8"))
# Lancement du serveur dans un thread séparé
serveur = http.server.HTTPServer(("localhost", 8080), SSEHandler)
thread = threading.Thread(target=serveur.serve_forever, daemon=True)
thread.start()
print("Serveur SSE démarré sur http://localhost:8080")
print("Flux d'événements sur http://localhost:8080/events")
# serveur.shutdown() # Pour arrêter
Client SSE en Python#
import httpx
import json
def consommer_sse(url: str, max_events: int = 10):
"""Consomme un flux SSE avec httpx."""
print(f"Connexion au flux SSE : {url}")
with httpx.stream("GET", url, headers={"Accept": "text/event-stream"}) as response:
print(f"Statut : {response.status_code}")
print(f"Content-Type : {response.headers.get('content-type')}")
buffer = ""
n_events = 0
for chunk in response.iter_text():
buffer += chunk
# Traitement des événements complets (séparés par \n\n)
while "\n\n" in buffer:
evenement, buffer = buffer.split("\n\n", 1)
if evenement.strip():
lignes = evenement.strip().split("\n")
event = {}
for ligne in lignes:
if ligne.startswith(":"):
continue # Commentaire
if ":" in ligne:
cle, _, valeur = ligne.partition(":")
event[cle.strip()] = valeur.strip()
if "data" in event:
n_events += 1
print(f"Événement #{n_events} [{event.get('event', 'message')}] : {event['data']}")
try:
data = json.loads(event["data"])
print(f" → T={data['t']:.1f}°C, H={data['h']:.1f}%")
except (json.JSONDecodeError, KeyError):
pass
if n_events >= max_events:
print(f"\nArrêt après {max_events} événements.")
return
consommer_sse("http://localhost:8080/events")
Exemple de flux SSE généré :
==================================================
id: 1
event: mesure
data: {"capteurA": 22.1, "capteurB": 18.9, "humidite": 55.8}
id: 2
event: mesure
data: {"capteurA": 22.1, "capteurB": 18.9, "humidite": 54.9}
id: 3
event: mesure
data: {"capteurA": 22.3, "capteurB": 18.6, "humidite": 56.3}
Comparaison WebSocket vs SSE vs Polling#
Cas d’usage et choix architectural#
Quand choisir WebSocket ?#
WebSocket est le choix approprié quand :
La communication est bidirectionnelle avec un volume élevé de messages dans les deux sens (chat, collaboration en temps réel, jeux multijoueurs)
La latence est critique (trading, jeux de tir à la première personne)
Le serveur doit recevoir fréquemment des données du client (position du curseur, frappe au clavier)
Exemples : Slack, Discord, Google Docs (curseurs collaboratifs), jeux HTML5, plateformes de trading.
Quand choisir SSE ?#
SSE est idéal quand :
Le flux est unidirectionnel : le serveur envoie des mises à jour que le client consomme
La simplicité est prioritaire : SSE fonctionne sur HTTP/2 naturellement (pas d’upgrade nécessaire)
La robustesse aux déconnexions est critique (reconnexion automatique native)
Derrière un load balancer standard (pas besoin de sticky sessions)
Exemples : tableaux de bord de monitoring, flux de prix, notifications push, logs en direct, progression de tâches longues.
SSE sur HTTP/2
Sur HTTP/2, SSE bénéficie du multiplexage : plusieurs flux SSE de différentes sources peuvent partager la même connexion TCP. Sur HTTP/1.1, le navigateur est limité à 6 connexions par domaine, ce qui restreint le nombre de flux SSE simultanés. HTTP/2 lève cette limitation.
Quand garder le polling ?#
Le polling reste valide quand :
La fréquence de mise à jour est basse (toutes les minutes ou plus)
La simplicité d’implémentation prime
L’infrastructure ne supporte pas les connexions longues (certains proxys d’entreprise)
La logique est simple et sans état côté serveur
Résumé#
Ce chapitre a présenté les deux protocoles majeurs pour la communication temps réel sur le web.
WebSocket offre un canal bidirectionnel plein-duplex à faible latence, idéal pour les applications interactives. Son handshake HTTP Upgrade (101 Switching Protocols) permet le passage à travers les pare-feux. Le masquage des frames côté client protège contre les attaques par cache poisoning.
Server-Sent Events sont plus simples, natifs HTTP, avec reconnexion automatique intégrée. Ils excellent pour les flux unidirectionnels (notifications, dashboards) et fonctionnent mieux avec les load balancers standard et HTTP/2.
Le polling reste pertinent pour des cas simples à faible fréquence, mais consomme des ressources inutiles à haute fréquence. Le long polling améliore la latence mais complexifie la gestion des connexions côté serveur.