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.

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.

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.

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, 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 pollingSSE
Requêtes par seconde2 000Environ 500Quasi nulles, hors reconnexions
Requêtes utiles1 sur 121 sur 3Sans objet
Délai moyen2,5 sQuasi immédiatQuasi immédiat
Connexions ouvertesTrès peuEnviron 10 00010 000
Cache HTTP partagéOuiNonNon
ServeurWSGI ou ASGIASGIASGI
Reconnexion et repriseRien à gérerÀ écrireNative avec EventSource
Limite HTTP/1.1 du navigateurSans effet notableOccupe une connexionOccupe 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.

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 ? ».