# Polling, long polling ou SSE : que choisir en Python ?

> Polling, long polling ou Server-Sent Events ? Fonctionnement, coût réel, cache HTTP et reprise après coupure, avec des exemples FastAPI pour bien choisir.

- URL: https://dev-flow.io/posts/polling-long-polling-sse/
- Date: 2026-10-05
- Author: Anthony
- Language: fr
- Categories: Architecture
- Tags: Python, Architecture, Api, Fastapi, Sse
- Translations: [en](https://dev-flow.io/en/posts/polling-long-polling-sse/index.md), [es](https://dev-flow.io/es/posts/polling-long-polling-sse/index.md)

---


Une commande change de statut, un export se termine, un message arrive. Le serveur le sait, le navigateur non. Polling, long polling et Server-Sent Events (SSE) répondent tous à cette même question, et on les confond souvent parce que le résultat à l'écran se ressemble.

Ce qui les sépare, c'est **qui déclenche l'envoi** et **ce que coûte le silence**, quand rien ne change. Voyons comment chacun fonctionne, ce qu'il coûte vraiment, et pourquoi la solution la plus simple est souvent la bonne.

**En bref :**

- **Polling** : simple et cachable, si quelques secondes de délai sont acceptables.
- **Long polling** : réactif sans flux continu, utile derrière un proxy qui bufferise.
- **SSE** : le bon choix pour pousser des événements fréquents vers un navigateur.

## Polling : le client redemande

Le client interroge le serveur à intervalle régulier. Rien de plus, et c'est sa grande force : une route HTTP ordinaire, des workers classiques, aucune connexion longue.

```python
import hashlib
import json
from typing import Annotated

from fastapi import FastAPI, Header, Response, status

app = FastAPI()


@app.get("/orders/{order_id}/status")
async def order_status(
    order_id: int,
    response: Response,
    if_none_match: Annotated[str | None, Header()] = None,
):
    current = await load_status(order_id)
    body = json.dumps(current, sort_keys=True).encode()
    etag = f'"{hashlib.sha256(body).hexdigest()[:16]}"'

    if if_none_match == etag:
        return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})

    response.headers["ETag"] = etag
    response.headers["Cache-Control"] = "no-cache"
    return current
```

L'`ETag` évite de renvoyer un corps identique : si rien n'a changé, le serveur répond `304` sans contenu. Attention à ce que cela économise réellement. La bande passante diminue, mais la requête a bien eu lieu, et `load_status` a été exécuté. Si cette lecture est lourde, c'est elle qu'il faut alléger, par exemple avec une [vue matérialisée](/posts/django-vues-materialisees-cache/).

Côté client, préférez `setTimeout` à `setInterval`. Avec `setInterval`, des requêtes lentes se chevauchent quand le serveur peine, au pire moment :

```javascript
async function poll() {
  const response = await fetch("/orders/42/status");
  if (response.ok) renderStatus(await response.json());
  setTimeout(poll, 5000 + Math.random() * 1000);
}
```

Le navigateur gère seul l'`If-None-Match` via son cache HTTP, et transmet un `200` à votre code même lorsque le serveur a répondu `304`. Le délai aléatoire évite que tous les clients frappent à la même seconde.

Le polling possède un atout que les deux autres n'ont pas : **le cache partagé**. Pour une donnée identique pour tout le monde, un `Cache-Control: public, max-age=5` sur un CDN transforme des milliers de requêtes en une seule vers votre serveur toutes les cinq secondes.

Son défaut est mathématique. Avec un intervalle de 5 secondes, le délai moyen est de 2,5 secondes, et 10 000 clients produisent 2 000 requêtes par seconde, même si rien ne bouge. Réduire le délai, c'est multiplier la charge.

## Long polling : le serveur retient sa réponse

Le long polling garde une requête ouverte jusqu'à ce qu'il y ait quelque chose à dire. Le client envoie sa position, le serveur attend, répond dès qu'un événement arrive, puis le client relance immédiatement.

```python
from typing import Annotated

from fastapi import FastAPI, Query, Response, status
from redis.asyncio import Redis

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


@app.get("/orders/{order_id}/updates")
async def order_updates(
    order_id: int,
    after: Annotated[str, Query(pattern=r"^\d+-\d+$")] = "0-0",
    wait: Annotated[int, Query(ge=1, le=25)] = 25,
):
    stream = f"orders:{order_id}:events"
    batches = await redis.xread({stream: after}, block=wait * 1000, count=100)
    if not batches:
        return Response(status_code=status.HTTP_204_NO_CONTENT)

    _, messages = batches[0]
    return {"events": [fields for _, fields in messages], "next": messages[-1][0]}
```

```javascript
async function longPoll(after) {
  try {
    const response = await fetch(`/orders/42/updates?after=${after}`);
    if (response.status === 200) {
      const { events, next } = await response.json();
      events.forEach(renderEvent);
      after = next;
    }
    longPoll(after);
  } catch {
    setTimeout(() => longPoll(after), 2000 + Math.random() * 3000);
  }
}
```

Le curseur `after` n'est pas un détail. Entre deux requêtes, le client n'écoute plus : sans position à reprendre, tout événement publié à ce moment-là serait perdu. Il faut donc une source qui conserve l'historique, ce qui exclut une simple diffusion. C'est toute la différence entre [Redis Pub/Sub et Streams](/posts/redis-pub-sub-vs-streams/).

Le paramètre `wait` plafonne à 25 secondes pour une raison pratique : les proxies et load balancers coupent les requêtes silencieuses trop longues (Nginx après 60 secondes par défaut, certaines plateformes dès 30 secondes). Mieux vaut répondre `204` proprement et laisser le client relancer.

Le long polling a aussi ses coûts propres. Sous WSGI, chaque requête en attente immobilise un worker : il exige un serveur asynchrone. Et chaque événement coûte un aller-retour HTTP complet, en-têtes compris. Le paramètre `count` limite les dégâts en regroupant les rafales, mais le débit reste inférieur à celui d'un flux continu.

Il reste précieux là où un flux en continu passe mal : derrière un proxy qui bufferise, ou pour un client qui n'a pas besoin d'un navigateur. L'API des bots Telegram expose ainsi `getUpdates`, du long polling avec un `offset` qui joue exactement le rôle de notre curseur.

## SSE : une seule réponse qui ne finit pas

SSE prolonge l'idée jusqu'au bout : au lieu de répondre puis d'attendre une nouvelle requête, le serveur garde la même réponse ouverte et y écrit chaque événement au format `text/event-stream`.

```text
Polling       Client → ?  Serveur → non  |  Client → ?  Serveur → non  |  Client → ?  Serveur → oui
Long polling  Client → ?  ........ Serveur → oui  |  Client → ?  .... Serveur → oui
SSE           Client → ?  ........ Serveur → oui ...... oui .. oui ........ oui
```

Le navigateur fournit gratuitement ce que le long polling vous fait écrire à la main : l'API `EventSource` se reconnecte seule et renvoie la dernière position dans l'en-tête `Last-Event-ID`. L'[implémentation avec FastAPI et Django, et la comparaison avec WebSocket](/posts/server-sent-events-vs-websocket/), font l'objet d'un article dédié.

En contrepartie, chaque client maintient une connexion ouverte, le cache HTTP ne sert plus à rien, et chaque proxy du chemin doit laisser passer le flux sans le retenir.

## Le coût réel, chiffré

Prenons 10 000 clients et un événement par client et par minute, soit environ 167 événements par seconde au total. C'est un ordre de grandeur, en supposant un polling à 5 secondes et un long polling plafonné à 25 secondes.

| | Polling (5 s) | Long polling | SSE |
|---|---|---|---|
| Requêtes par seconde | 2 000 | Environ 500 | Quasi nulles, hors reconnexions |
| Requêtes utiles | 1 sur 12 | 1 sur 3 | Sans objet |
| Délai moyen | 2,5 s | Quasi immédiat | Quasi immédiat |
| Connexions ouvertes | Très peu | Environ 10 000 | 10 000 |
| Cache HTTP partagé | Oui | Non | Non |
| Serveur | WSGI ou ASGI | ASGI | ASGI |
| Reconnexion et reprise | Rien à gérer | À écrire | Native avec `EventSource` |
| Limite HTTP/1.1 du navigateur | Sans effet notable | Occupe une connexion | Occupe une connexion |

Le tableau montre que la vraie comparaison ne porte pas sur le nombre de requêtes seul. Le polling consomme du CPU et du réseau en permanence, là où les deux autres consomment des connexions et de la mémoire. Et 10 000 connexions ouvertes ne pèsent rien tant qu'elles restent gérées par une boucle asynchrone, mais elles compliquent chaque déploiement : au redémarrage, tous les clients reviennent en même temps.

## Polling, long polling ou SSE : comment choisir

Partez du besoin de fraîcheur, et prenez l'option la plus simple qui le satisfait.

**Le polling** convient quand quelques secondes de délai ne gênent personne, quand les données sont partagées et cachables, ou quand l'infrastructure ne doit pas changer. Un tableau de bord rafraîchi toutes les 30 secondes n'a besoin de rien d'autre.

**Le long polling** convient quand il faut de la réactivité mais qu'un flux continu ne passe pas : proxies d'entreprise qui bufferisent, clients qui ne sont pas des navigateurs, intégrations simples côté serveur.

**SSE** convient dès qu'un navigateur doit recevoir des événements fréquents avec peu de délai : notifications, progression, réponses de LLM affichées au fil de l'eau.

Et si le client doit lui aussi émettre en continu, on quitte ce trio pour [WebSocket](/posts/server-sent-events-vs-websocket/).

Un dernier conseil : dans tous les cas, gardez une lecture complète de l'état. Une reprise peut échouer, un curseur peut être trop ancien. Le flux dit « quelque chose a changé », et la lecture classique reste la source de vérité.

## Conclusion

Polling, long polling et SSE ne sont pas trois niveaux de qualité, mais trois façons de répartir un coût. Le polling paie à chaque vérification, le long polling à chaque événement, SSE une seule fois à la connexion, puis dans la durée en connexions à maintenir.

La bonne question n'est donc pas « quelle est la technique la plus moderne ? », mais « de quelle fraîcheur ai-je réellement besoin, et que suis-je prêt à opérer pour l'obtenir ? ».

