# Server-Sent Events vs WebSocket : lequel choisir en Python ?

> Server-Sent Events ou WebSocket ? Comparatif concret en Python avec FastAPI et Django : reprise après coupure, fan-out Redis, proxies et critères de choix.

- URL: https://dev-flow.io/posts/server-sent-events-vs-websocket/
- Date: 2026-09-15
- Author: Anthony
- Language: fr
- Categories: Architecture
- Tags: Python, Architecture, Api, Fastapi, Django, Websocket, Sse
- Translations: [en](https://dev-flow.io/en/posts/server-sent-events-vs-websocket/index.md), [es](https://dev-flow.io/es/posts/server-sent-events-vs-websocket/index.md)

---


Dès qu'un écran doit se mettre à jour sans recharger la page, WebSocket arrive dans la conversation. Suivi d'une commande, progression d'un export, notifications, réponse d'un LLM qui s'affiche token par token : le réflexe est d'ouvrir un canal bidirectionnel. Pourtant, dans tous ces cas, le client n'a rien à dire au serveur une fois la connexion établie.

Le pattern **Server-Sent Events** (SSE) répond exactement à ce besoin avec une simple réponse HTTP qui ne se termine pas. Ce choix n'est pas qu'une question de protocole. Il détermine ce que vous récupérez gratuitement (reconnexion, reprise, authentification HTTP) et ce que vous devrez construire vous-même. Comparons les deux en Python, avec FastAPI et Django, jusqu'aux problèmes qui n'apparaissent qu'en production.

**En bref :**

- **SSE** : le bon défaut quand le serveur parle, avec reconnexion et reprise natives.
- **WebSocket** : quand le client émet en continu ou que les données sont binaires.
- **Polling** : quand les données changent rarement et qu'un délai ne gêne personne.

## SSE : une réponse HTTP qui reste ouverte

Avec SSE, le navigateur envoie un `GET` classique. Le serveur répond avec `Content-Type: text/event-stream`, puis écrit des blocs de texte au fil de l'eau :

```text
id: 1726394400000-0
event: order.shipped
data: {"order_id": 42, "carrier": "colissimo"}

: ping

id: 1726394460000-0
event: order.delivered
data: {"order_id": 42}

```

Une ligne vide termine un événement. Une ligne commençant par `:` est un commentaire ignoré par le client, ce qui en fait un keep-alive idéal. Côté navigateur, l'API `EventSource` tient en quelques lignes :

```javascript
const source = new EventSource("/orders/42/events", { withCredentials: true });

source.addEventListener("order.shipped", (event) => {
  renderShipment(JSON.parse(event.data));
});
```

Le plus intéressant reste ce que ce code ne contient pas. Si le réseau coupe, le navigateur se reconnecte seul et renvoie l'identifiant du dernier événement reçu dans l'en-tête `Last-Event-ID`. Le serveur peut alors reprendre exactement là où le flux s'était arrêté.

## WebSocket : un autre protocole après le handshake

WebSocket commence aussi par une requête HTTP, mais celle-ci demande un changement de protocole (`Upgrade: websocket`). Après la réponse `101 Switching Protocols`, ce n'est plus du HTTP. Client et serveur échangent des trames texte ou binaires, dans les deux sens et à tout moment.

Cette liberté est réelle, mais le protocole s'arrête là. Il n'y a ni identifiant de message, ni reprise, ni reconnexion automatique dans l'API navigateur. Tout cela reste à concevoir :

```python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()


@app.websocket("/ws/rooms/{room_id}")
async def room(websocket: WebSocket, room_id: int) -> None:
    await websocket.accept()
    try:
        while True:
            message = await websocket.receive_json()
            await handle_client_message(room_id, message)
    except WebSocketDisconnect:
        await handle_disconnect(room_id)
```

Ce handler a l'air court, mais il laisse ouvertes les vraies questions : comment le client signale-t-il le dernier message reçu après une coupure ? Qui rejoue ce qu'il a manqué ? Que se passe-t-il s'il envoie un message pendant la reconnexion ? Avec WebSocket, votre application définit son propre protocole au-dessus du protocole.

## SSE vs WebSocket : le comparatif

| Critère | Server-Sent Events | WebSocket |
|---|---|---|
| Direction | Serveur vers client | Bidirectionnelle |
| Données | Texte UTF-8 | Texte et binaire |
| Transport | HTTP standard | Protocole dédié après `Upgrade` |
| Reconnexion | Native avec `EventSource` | À implémenter côté client |
| Reprise | `Last-Event-ID`, si le serveur la gère | Protocole applicatif à concevoir |
| Authentification | Cookies et middlewares HTTP | Handshake, puis état à gérer soi-même |
| Protection d'origine | CORS s'applique | Vérification de `Origin` à votre charge |
| Limite navigateur | 6 connexions par domaine en HTTP/1.1 | Pas de limite aussi basse |
| Django | Vue async sous ASGI | Django Channels |

Deux lignes méritent une explication.

**La limite de connexions.** En HTTP/1.1, les navigateurs plafonnent à environ six connexions par domaine, tous onglets confondus. Six onglets ouverts sur votre application avec un flux SSE chacun, et plus aucune requête ne part. HTTP/2 règle le problème en multiplexant les flux sur une seule connexion TCP. Servir SSE en HTTP/2 devient donc un prérequis dès que les utilisateurs ouvrent plusieurs onglets.

**L'origine.** La politique de même origine ne protège pas un handshake WebSocket. Si l'authentification repose sur un cookie, un site tiers peut ouvrir une connexion avec les identifiants de l'utilisateur, à moins que le serveur ne contrôle l'en-tête `Origin`. C'est le Cross-Site WebSocket Hijacking. Oublier ce contrôle est facile, parce que rien ne casse pendant le développement.

## SSE avec FastAPI : reprise après coupure

Depuis la version 0.135.0, FastAPI gère SSE nativement avec `EventSourceResponse`. Il s'occupe du format, envoie un ping toutes les 15 secondes quand le flux est silencieux et pose les en-têtes `Cache-Control: no-cache` et `X-Accel-Buffering: no`.

Le vrai sujet est ailleurs. Une réponse SSE vit dans un seul worker, alors que l'événement est produit n'importe où : un autre worker, une tâche Celery, un autre service. Il faut un intermédiaire, et il doit conserver l'historique pour permettre la reprise. Redis pub/sub diffuse bien mais oublie tout : un client déconnecté trois secondes perd ces trois secondes. Redis Streams couvre la diffusion et le rejeu avec un seul mécanisme (voir le [comparatif Redis Pub/Sub et Streams](/posts/redis-pub-sub-vs-streams/)) :

```python
from collections.abc import AsyncIterable
from typing import Annotated

from fastapi import Depends, FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent
from redis.asyncio import Redis

app = FastAPI()
redis = Redis.from_url("redis://localhost:6379", decode_responses=True)

STREAM_ID_PATTERN = r"^\d+-\d+$"


async def latest_id(stream: str) -> str:
    entries = await redis.xrevrange(stream, count=1)
    return entries[0][0] if entries else "0-0"


@app.get(
    "/orders/{order_id}/events",
    response_class=EventSourceResponse,
    dependencies=[Depends(require_order_access)],
)
async def order_events(
    order_id: int,
    last_event_id: Annotated[
        str | None, Header(pattern=STREAM_ID_PATTERN)
    ] = None,
) -> AsyncIterable[ServerSentEvent]:
    stream = f"orders:{order_id}:events"
    cursor = last_event_id or await latest_id(stream)

    while True:
        batches = await redis.xread({stream: cursor}, block=5_000, count=100)
        for _, messages in batches:
            for message_id, fields in messages:
                cursor = message_id
                yield ServerSentEvent(
                    raw_data=fields["payload"],
                    event=fields["type"],
                    id=message_id,
                )
```

Trois détails font la différence entre une démo et un flux fiable.

L'identifiant SSE **est** l'identifiant Redis. Il n'y a aucune table de correspondance à tenir : `Last-Event-ID` devient directement le curseur de `XREAD`.

Le curseur initial est résolu une seule fois avec `latest_id`. Passer `$` à chaque tour de boucle semble plus simple, mais `$` désigne « le dernier au moment de l'appel » : un événement publié entre deux `XREAD` serait perdu.

`raw_data` évite une double sérialisation. Le champ `data` de `ServerSentEvent` encode toujours en JSON, or `payload` est déjà une chaîne JSON.

Côté producteur, on borne la rétention :

```python
await redis.xadd(
    f"orders:{order_id}:events",
    {"type": "order.shipped", "payload": payload_json},
    maxlen=1_000,
    approximate=True,
)
```

Cet appel ne doit pas être fait n'importe où. Publier dans Redis au milieu d'une transaction SQL, c'est risquer d'annoncer une commande expédiée qui sera finalement annulée par un rollback. Le [Transactional Outbox](/posts/pattern-transactional-outbox/) résout précisément ce problème : l'événement est écrit dans la même transaction que l'état métier, puis un relay l'envoie dans le stream.

## SSE avec Django : pas besoin de Channels

Django n'a pas d'abstraction SSE, mais `StreamingHttpResponse` accepte un itérateur asynchrone sous ASGI. C'est suffisant. Ici, le ping et les en-têtes sont à votre charge :

```python
import re

from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import StreamingHttpResponse

from .models import Order
from .streams import latest_id, redis

STREAM_ID = re.compile(r"\d+-\d+")


async def event_stream(stream: str, cursor: str):
    while True:
        batches = await redis.xread({stream: cursor}, block=15_000, count=100)
        if not batches:
            yield ": ping\n\n"
            continue
        for _, messages in batches:
            for message_id, fields in messages:
                cursor = message_id
                yield (
                    f"id: {message_id}\n"
                    f"event: {fields['type']}\n"
                    f"data: {fields['payload']}\n\n"
                )


async def order_events(request, order_id: int):
    user = await request.auser()
    allowed = await Order.objects.filter(pk=order_id, owner_id=user.pk).aexists()
    if not allowed:
        return HttpResponseForbidden()

    last_event_id = request.headers.get("Last-Event-ID")
    if last_event_id and not STREAM_ID.fullmatch(last_event_id):
        return HttpResponseBadRequest()

    stream = f"orders:{order_id}:events"
    cursor = last_event_id or await latest_id(stream)

    response = StreamingHttpResponse(
        event_stream(stream, cursor),
        content_type="text/event-stream",
    )
    response["Cache-Control"] = "no-cache"
    response["X-Accel-Buffering"] = "no"
    return response
```

La vérification d'accès se fait dans la vue, pas dans le générateur, pour pouvoir encore renvoyer un vrai statut HTTP. Une fois le premier octet parti, il est trop tard pour répondre 403.

Cette vue doit tourner sous ASGI, avec Uvicorn ou Daphne par exemple. Sous WSGI, chaque flux ouvert immobilise un worker pour toute sa durée : quelques clients suffisent à bloquer l'application. Quand un client se déconnecte, Django annule la coroutine. Si vous avez des ressources à libérer, interceptez `asyncio.CancelledError` et relancez-la.

La comparaison est parlante : pour pousser des événements, une vue suffit. WebSocket impose Django Channels, son routing, ses consumers et son channel layer. Ce coût se justifie quand le client doit réellement parler, pas pour afficher un statut de commande.

## Ce qui casse en production

### Les proxies

Nginx bufferise les réponses par défaut, et votre flux n'arrive qu'une fois le tampon plein. L'en-tête `X-Accel-Buffering: no` le désactive, mais il est plus sûr de configurer aussi le proxy. Les besoins diffèrent selon le protocole :

```nginx
location /orders/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
}

location /ws/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 1h;
}
```

Méfiez-vous aussi de la compression : un `GZipMiddleware` ou un CDN qui compresse le flux peut retenir les événements dans ses tampons, exactement comme le buffering.

### Le 502 qui arrête tout

`EventSource` se reconnecte après une erreur réseau, mais un statut autre que 200 ferme définitivement la connexion. Un 502 renvoyé par le proxy pendant un déploiement suffit donc à arrêter le flux, sans nouvelle tentative. Surveillez cet état :

```javascript
source.onerror = () => {
  if (source.readyState === EventSource.CLOSED) {
    scheduleReconnectWithJitter();
  }
};
```

Le délai aléatoire compte. Sans lui, tous les clients coupés par un déploiement reviennent à la même seconde.

### Une connexion Redis par client

Le code ci-dessus maintient un `XREAD` bloquant par flux, donc une connexion Redis par client connecté. C'est lisible et correct jusqu'à un certain volume. Au-delà, chaque worker doit lire les streams avec une seule boucle partagée, puis redistribuer aux clients locaux via des `asyncio.Queue` bornées. La borne est importante : un client lent ne doit pas faire grossir la mémoire du worker. WebSocket a exactement le même problème de fan-out et de backpressure.

### Les doublons et les trous

La reprise n'est pas une garantie absolue. Si le client revient après que `MAXLEN` a évincé son curseur, `XREAD` repart des entrées encore disponibles et un trou passe inaperçu. Les données du stream servent à rattraper une coupure, pas à reconstruire un état : pour une absence longue, le client recharge une vue complète. C'est la même séparation que dans un [read model CQRS](/posts/cqrs-pragmatique-django/), où le flux signale un changement et la lecture fait foi.

Le relay de l'outbox étant at-least-once, un même événement peut aussi être publié deux fois, avec deux identifiants de stream différents. Incluez un identifiant métier dans le payload et ignorez ce qui a déjà été traité, selon la logique du [consommateur idempotent](/posts/pattern-inbox-consumer-idempotent/).

### L'authentification

`EventSource` ne permet ni en-tête personnalisé, ni `POST`. Sans cookie, il reste un ticket à usage unique et courte durée dans l'URL, jamais le JWT principal, car les URL finissent dans les logs. L'API `WebSocket` du navigateur a la même limite sur les en-têtes.

L'autre option est de lire le flux avec `fetch`. C'est ainsi que les API de LLM fonctionnent : un `POST` qui répond en `text/event-stream`. On gagne les en-têtes et le corps de requête, on perd la reconnexion native. FastAPI accepte d'ailleurs `EventSourceResponse` sur n'importe quelle méthode HTTP.

## Et si le client doit quand même envoyer des données ?

Un besoin de communication dans les deux sens ne suffit pas à justifier WebSocket. Si le client envoie peu, SSE pour la descente et des `POST` classiques pour la montée forment une combinaison très solide :

```text
Navigateur ── POST /orders/42/cancel ──▶ API
Navigateur ◀── event: order.cancelled ── flux SSE
```

Chaque action conserve le routing, la validation, les permissions et les codes d'erreur HTTP. Pour rendre ces `POST` rejouables sans risque après une coupure, ajoutez des [Idempotency Keys](/posts/pattern-idempotency-keys-api/).

## SSE ou polling ?

Ce n'est pas la même chose. Avec le polling, le client redemande sans cesse si quelque chose a changé. Avec SSE, il demande une seule fois, et le serveur envoie quand il a du nouveau. Le long polling se situe entre les deux, et chacun a sa place : le [comparatif polling, long polling et SSE](/posts/polling-long-polling-sse/) détaille leur fonctionnement et leur coût.

Notez que `XREAD` avec `block` est lui-même une forme de long polling, mais entre le worker et Redis. Le navigateur, lui, ne voit qu'un flux SSE continu.

## SSE ou WebSocket : comment choisir

**Prenez SSE** quand le flux va essentiellement du serveur vers le client : notifications, progression de tâches, tableaux de bord, statuts, streaming de réponses de LLM. C'est le bon défaut, et l'infrastructure HTTP existante continue de fonctionner.

**Prenez WebSocket** quand le client émet en continu ou que la latence dans les deux sens fait partie du produit : édition collaborative, jeu multijoueur, curseurs partagés, chat avec indicateurs de frappe, ou données binaires.

**Gardez le polling** si les données changent rarement et qu'un délai de trente secondes ne gêne personne. Aucune connexion longue à opérer, et un cache HTTP utilisable.

La question utile n'est donc pas « quel protocole est le plus puissant ? », mais « qui parle, à quelle fréquence, et que se passe-t-il après une coupure ? ».

## Conclusion

WebSocket est le bon outil pour une vraie conversation à double sens. Pour tout le reste, il vous fait payer une reconnexion, une reprise et une sécurité que SSE ou HTTP offrent déjà.

Quel que soit le protocole, le plus difficile se situe derrière lui : un bus qui diffuse entre les workers, un journal qui permet de rejouer, une publication fiable après le commit et des consommateurs qui tolèrent les doublons. Une fois ces fondations en place, le protocole choisi n'est plus qu'un détail de livraison, et SSE est très souvent le détail le plus simple à exploiter.

