Comment interroger une API par polling sans surcharger votre serveur
Créez un poller API fiable en production : une requête à la fois, annulation, attente exponentielle avec jitter, gestion de Retry-After, pause des onglets cachés et ETag.
Un mécanisme de polling prêt pour la production n’envoie la requête suivante qu’une fois la réponse précédente traitée. Il s’arrête lorsque la tâche atteint un état terminal et applique un backoff avec jitter en cas d’échec. Il respecte Retry-After sur les réponses 429 et 503, ne fait rien tant que l’onglet est masqué, et utilise les ETags pour que des données inchangées reviennent sous la forme d’une réponse 304 vide.
La plupart des pollers commencent par un simple setInterval associé à fetch. Cette version fonctionne jusqu’à ce que l’endpoint ralentisse, que l’API commence à renvoyer des 429, ou que quelqu’un laisse un onglet ouvert pendant le week-end et que celui-ci envoie encore des requêtes le lundi.
Si votre serveur peut pousser les mises à jour, vous n’avez peut-être pas besoin de polling du tout. Comparez WebSockets, SSE et long polling, ou envisagez les webhooks plutôt que le polling pour les échanges de serveur à serveur. Si le polling est le bon outil, cet article construit un poller sans dépendance, un correctif à la fois. Chaque section présente d’abord la version défaillante, puis sa correction.
Points clés à retenir
- Enchaîner un
setTimeoutaprès chaque réponse garantit qu’une seule requête est en cours à tout moment, ce quesetIntervalne peut pas garantir. - Le « full jitter » fixe chaque délai de nouvelle tentative à
random(0, min(cap, base * 2 ** attempt)): les clients qui réessaient s’étalent dans le temps au lieu d’arriver tous en même temps sur un serveur en cours de rétablissement. Retry-Afterse présente sous deux formes, un nombre de secondes ou une date HTTP. Un poller doit savoir interpréter les deux et revenir au backoff lorsque l’en-tête est absent ou impossible à analyser.- Les navigateurs limitent les timers dans les onglets en arrière-plan, mais ce bridage ne fait que ralentir le poller. C’est la vérification de
document.hiddenqui l’arrête réellement. - Renvoyer le dernier
ETagdansIf-None-Matchpermet au serveur de répondre304 Not Modifiedsans corps lorsque rien n’a changé.
Quelles sont les quatre erreurs courantes du polling d’API ?
Un polling d’API naïf échoue généralement de quatre façons : les requêtes se chevauchent, le polling ne s’arrête jamais, les échecs poussent le poller à solliciter le serveur encore plus fort, et les onglets masqués continuent d’interroger l’API. Ces quatre problèmes sont tous présents dans cet extrait :
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- Chevauchement.
setIntervalse déclenche selon une horloge fixe, et non après l’arrivée de la réponse précédente. Lorsque l’endpoint met plus de temps à répondre que l’intervalle, les requêtes s’empilent les unes sur les autres. - Aucune sortie. Rien n’arrête la boucle une fois la tâche terminée : elle continue donc de récupérer un résultat qui ne changera plus jamais.
- Aucun backoff. Lorsque le serveur renvoie des 500, chaque client continue de réessayer à pleine vitesse, ce qui ajoute de la charge au moment précis où le serveur est le moins en mesure de la supporter.
- Onglets masqués. Un onglet que personne ne regarde continue d’interroger l’API.
Comment enchaîner des setTimeout et s’arrêter sur une condition de fin ?
Si vous ne planifiez le polling suivant qu’après le traitement de la réponse courante, il n’y a jamais plus d’une requête en cours. Une boucle async avec une pause entre les itérations produit exactement ce comportement, et elle se termine dès que la tâche atteint un état terminal :
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function pollJob(url, isDone, interval = 2000) {
while (true) {
const res = await fetch(url);
const data = await res.json();
render(data);
if (isDone(data)) return data;
await sleep(interval);
}
}
pollJob('/api/jobs/42', (job) => job.status === 'done' || job.status === 'failed');
La condition de fin doit couvrir les échecs terminaux autant que le succès. Si vous ne vérifiez que 'done', une tâche qui se termine en 'failed' sera interrogée indéfiniment.
Comment annuler un poller avec AbortController ?
Annuler un poller implique d’arrêter deux choses : la requête en cours et le timer en attente. Un signal AbortController transmis à fetch gère la requête, mais n’a aucun effet sur un setTimeout déjà planifié. La fonction de pause doit donc, elle aussi, écouter le signal :
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(id);
reject(signal.reason);
};
const id = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
L’appel à controller.abort() rejette celle des deux opérations qui est en attente, qu’il s’agisse du fetch ou du sleep, et la boucle se termine. L’écouteur abort est retiré lorsque le timer se déclenche. Ainsi, un polling de longue durée qui réutilise un même signal n’ajoute pas un nouvel écouteur à chaque itération.
Comment appliquer un backoff exponentiel avec jitter et plafond ?
Lorsqu’une requête échoue, attendez random(0, min(cap, base * 2 ** attempt)) avant de réessayer. Il s’agit de la stratégie « full jitter » décrite dans l’analyse Exponential Backoff and Jitter d’AWS. Un backoff exponentiel plafonné sans composante aléatoire laisse les clients réessayer en grappes synchronisées. Choisir chaque délai au hasard entre zéro et le plafond exponentiel permet de disperser ces grappes.
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
Cette fonction backoff d’une ligne correspond à la fonction full jitter du code source du simulateur de backoff d’AWS. Dans la simulation menée par AWS en 2015, où 100 clients se disputaient la même ressource, l’ajout de jitter a réduit le nombre total d’appels de plus de moitié. Notez que cette simulation modélisait une contention en écriture, et non un polling en lecture. Considérez donc ce chiffre comme le résultat obtenu par AWS dans ce scénario précis.
Dans la boucle, incrémentez attempt après chaque échec et remettez-le à 0 après chaque succès. Sans cette réinitialisation, un simple incident ponctuel en début de session ferait démarrer chaque échec ultérieur avec un délai élevé. Les workers côté serveur sont confrontés au même problème de nouvelles tentatives, et les files de tâches le traitent avec des politiques de retry similaires.
Comment un poller doit-il gérer les 429, les 503 et Retry-After ?
Lorsque le serveur envoie une réponse 429 ou 503 accompagnée d’un en-tête Retry-After, attendez la durée indiquée par cet en-tête. Votre propre backoff sert de solution de repli lorsque l’en-tête est absent. L’en-tête Retry-After prend l’une de deux formes : un nombre entier de secondes à attendre après la réception de la réponse, ou une date HTTP à partir de laquelle vous pouvez réessayer. La RFC 9110 définit ces deux formes, et MDN décrit son utilisation avec le code 429 Too Many Requests.
const MAX_RETRY_AFTER = 5 * 60_000; // design choice, not a spec rule
function retryAfterMs(res) {
const value = res.headers.get('Retry-After');
if (!value) return null;
if (/^\d+$/.test(value.trim())) return Math.min(Number(value) * 1000, MAX_RETRY_AFTER);
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.min(Math.max(0, date - Date.now()), MAX_RETRY_AFTER);
}
La forme date est comparée à l’horloge du client. Si les horloges divergent et que la date est déjà passée, le délai est ramené à zéro. Si Date.parse renvoie NaN, la fonction renvoie null et l’appelant utilise le backoff à la place. La borne supérieure empêche un en-tête mal configuré de bloquer le poller pendant des heures.
Sur les requêtes cross-origin, JavaScript ne peut lire Retry-After que si le serveur le liste dans Access-Control-Expose-Headers, car il ne fait pas partie des en-têtes de réponse CORS autorisés par défaut (CORS-safelisted). S’il n’est pas exposé, res.headers.get() renvoie null et le poller se rabat sur un simple backoff, sans la moindre erreur.
Comment mettre le polling en pause lorsque l’onglet est masqué ?
Les navigateurs limitent les timers dans les onglets en arrière-plan, mais un poller bridé continue d’envoyer des requêtes, simplement moins souvent. Pour l’arrêter réellement, vérifiez document.hidden et écoutez l’événement visibilitychange, tous deux issus de l’API Page Visibility. Cette fonction utilitaire se résout immédiatement lorsque la page est visible et, dans le cas contraire, attend qu’elle le devienne :
function whenVisible(signal) {
if (signal?.aborted) return Promise.reject(signal.reason);
if (!document.hidden) return Promise.resolve();
return new Promise((resolve, reject) => {
const done = () => {
document.removeEventListener('visibilitychange', onChange);
signal?.removeEventListener('abort', onAbort);
};
const onChange = () => { if (!document.hidden) { done(); resolve(); } };
const onAbort = () => { done(); reject(signal.reason); };
document.addEventListener('visibilitychange', onChange);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
Si vous l’attendez (await) au début de chaque itération, un onglet masqué n’envoie aucune requête, et la première requête après le retour de l’utilisateur part immédiatement. Une limite toutefois : si une longue pause de backoff est déjà en cours lorsque l’onglet redevient visible, le poller attend d’abord qu’elle se termine.
Utiliser les ETags, puis tout assembler
Si vous renvoyez le dernier ETag dans un en-tête If-None-Match, le serveur peut répondre 304 Not Modified sans corps lorsque la ressource n’a pas changé. Définir vous-même If-None-Match tout en conservant le mode de cache par défaut amène la spécification Fetch à contourner le cache HTTP. Votre code reçoit donc directement la 304 et doit la gérer. Deux subtilités CORS s’appliquent aux requêtes cross-origin. D’une part, le serveur doit exposer ETag. D’autre part, comme If-None-Match ne fait pas partie des en-têtes de requête CORS autorisés par défaut, son envoi déclenche une requête preflight : le serveur doit donc aussi le lister dans Access-Control-Allow-Headers.
Ce tableau montre comment le poller final réagit à chaque cas de figure (la signification des codes de statut figure dans la référence des codes HTTP mentionnée plus haut) :
| Réponse | Action du poller | Délai suivant |
|---|---|---|
| 200 | Met à jour l’UI, vérifie la condition de fin, réinitialise les tentatives | interval |
| 304 | Aucune mise à jour, réinitialise les tentatives | interval |
| 429 / 503 | Incrémente les tentatives | Retry-After, sinon backoff |
| Autre 5xx / erreur réseau | Incrémente les tentatives | Backoff |
| Autre 4xx | Lève l’erreur et la remonte | Aucun |
| Abort | Relance l’erreur et sort | Aucun |
La fonction ci-dessous utilise sleep, backoff, retryAfterMs et whenVisible définies dans les sections précédentes. Le branchement sur le code de statut se situe en dehors du try/catch : une 4xx n’est donc jamais confondue avec une erreur réseau, ni réessayée.
async function poll(url, { isDone = () => false, onUpdate, signal, interval = 2000, maxAttempts = 8 } = {}) {
let etag = null;
let attempt = 0;
while (true) {
await whenVisible(signal);
let res = null;
try {
res = await fetch(url, { signal, headers: etag ? { 'If-None-Match': etag } : {} });
} catch (err) {
if (signal?.aborted) throw err; // otherwise: network error, res stays null
}
let wait = interval;
if (res?.status === 304) {
attempt = 0; // no body: never call res.json() here
} else if (res?.ok) {
attempt = 0;
etag = res.headers.get('ETag'); // null if absent or not exposed
const data = await res.json();
onUpdate?.(data);
if (isDone(data)) return data;
} else if (!res || res.status === 429 || res.status >= 500) {
if (++attempt > maxAttempts) throw new Error('Polling gave up');
wait = (res && retryAfterMs(res)) ?? backoff(attempt);
} else {
throw new Error(`Polling failed: HTTP ${res.status}`);
}
await sleep(wait, signal);
}
}
Comme attempt est pré-incrémenté, la première nouvelle tentative utilise déjà attempt = 1, ce qui plafonne ce délai à 2 secondes.
L’encapsuler dans un hook React
import { useEffect, useState } from 'react';
function usePoll(url, isDone) {
const [state, setState] = useState({ status: 'polling', data: null });
useEffect(() => {
const controller = new AbortController();
poll(url, { isDone, signal: controller.signal, onUpdate: (data) => setState({ status: 'polling', data }) })
.then((data) => setState({ status: 'done', data }))
.catch((error) => { if (!controller.signal.aborted) setState({ status: 'error', error }); });
return () => controller.abort();
}, [url]);
return state;
}
React exécute la fonction de nettoyage de l’effet au démontage, ainsi qu’avant de relancer l’effet lorsque url change : l’ancien poller est donc toujours annulé. Définissez isDone en dehors du composant pour qu’elle reste stable. Le statut 'error' permet à l’UI d’afficher un état du type « Nous avons arrêté la vérification. Réessayer ? ». Les session replays de pollers qui n’exposent pas leur état montrent souvent un spinner qui tourne indéfiniment, alors que les requêtes réseau sous-jacentes se sont arrêtées ou continuent d’échouer.
Conclusion
Un poller qui ne surcharge pas votre serveur repose sur quelques règles : une seule requête à la fois, une condition de sortie claire, un backoff avec jitter réinitialisé après chaque succès, le respect de Retry-After, aucune requête depuis les onglets masqués, et des requêtes conditionnelles pour que des données inchangées ne coûtent presque rien. Remplacez votre setInterval par la fonction poll ci-dessus. Vérifiez ensuite que votre serveur renvoie bien ETag et Retry-After, et qu’il expose ces deux en-têtes aux clients cross-origin. Sans ce support côté serveur, le code client se rabat silencieusement sur un simple backoff et sur des réponses complètes.
FAQ
Le poller se met-il en pause lorsque l'utilisateur bascule vers une autre fenêtre d'application ?
Pas toujours. Une page devient masquée lorsque son onglet passe en arrière-plan, lorsque la fenêtre du navigateur est réduite ou lorsque le verrouillage d'écran du système s'active. Une page qui reste partiellement visible à l'écran conserve un visibilityState égal à 'visible', et la perte du focus au profit d'une autre fenêtre ne la masque pas. Dans ce cas, le poller continue de tourner. Écoutez les événements blur et focus si vous souhaitez également le mettre en pause lorsque la fenêtre perd le focus.
Quelle est la différence entre full jitter, equal jitter et decorrelated jitter ?
Chacune de ces stratégies rend aléatoire le backoff exponentiel plafonné d'une manière différente. Le full jitter choisit un délai entre zéro et min(cap, base * 2 ** attempt). L'equal jitter conserve une moitié de ce plafond fixe et rend l'autre moitié aléatoire. Le decorrelated jitter choisit un délai entre base et trois fois le délai précédent, dans la limite du plafond. Dans la simulation d'AWS, l'equal jitter s'est révélé le moins performant des trois, tandis que le full jitter a généré moins de travail que le decorrelated jitter, mais a pris un peu plus de temps.
Puis-je utiliser ce poller avec Node.js ?
Oui, moyennant une modification. Node.js 18 et les versions ultérieures fournissent un fetch global, devenu stable avec Node.js 21, ainsi qu'un AbortController global. Node.js ne dispose pas d'objet document : whenVisible lève donc une ReferenceError. Ajoutez une vérification typeof document qui se résout immédiatement hors du navigateur. La gestion du backoff, de Retry-After et des ETags fonctionne sans changement. Node.js n'applique pas CORS, si bien que tous les en-têtes de réponse sont lisibles.
Pourquoi fetch ne renvoie-t-il jamais de 304 lorsque je ne définis pas If-None-Match moi-même ?
Le cache HTTP du navigateur gère la revalidation en interne. Lorsqu'une réponse stockée doit être revalidée, le navigateur ajoute lui-même les en-têtes conditionnels. Si le serveur répond 304, fetch transmet à votre code la réponse mise en cache avec un statut 200 et le corps complet : vous ne pouvez donc pas savoir que la requête était conditionnelle. C'est le fait de définir If-None-Match manuellement qui rend la 304 visible pour JavaScript.