Eine API pollen, ohne den Server zu überlasten
Erstellen Sie einen produktionssicheren API-Poller mit Einzelanfragen, Abbruch, Jitter-Backoff, Retry-After, Pausen für verborgene Tabs und ETag-Caching.
Ein produktionstauglicher API-Poller sendet die nächste Anfrage erst, nachdem die vorherige Antwort verarbeitet wurde. Er stoppt, sobald der Job einen Endzustand erreicht, wendet bei fehlgeschlagenen Anfragen Backoff mit Jitter an und befolgt Retry-After bei 429 und 503. Solange der Tab ausgeblendet ist, bleibt er untätig, und er nutzt ETags, damit unveränderte Daten als leere 304-Antwort zurückkommen.
Die meisten Poller beginnen als setInterval plus fetch. Diese Variante funktioniert so lange, bis der Endpunkt langsamer wird, die API anfängt, 429-Antworten zu liefern, oder jemand einen Tab übers Wochenende offen lässt, der am Montag immer noch Anfragen sendet.
Wenn Ihr Server Updates per Push ausliefern kann, brauchen Sie Polling womöglich gar nicht. Vergleichen Sie WebSockets, SSE und Long Polling oder ziehen Sie für Server-zu-Server-Abläufe Webhooks statt Polling in Betracht. Falls Polling das richtige Werkzeug ist, baut dieser Artikel Schritt für Schritt einen Poller ohne externe Abhängigkeiten auf. Jeder Abschnitt zeigt zuerst die fehlerhafte Variante und danach die Korrektur.
Das Wichtigste in Kürze
- Wird
setTimeoutnach jeder Antwort verkettet, ist immer nur eine Anfrage gleichzeitig unterwegs. Das kannsetIntervalnicht garantieren. - Full Jitter setzt jede Retry-Verzögerung auf
random(0, min(cap, base * 2 ** attempt)). Dadurch verteilen sich wiederholende Clients, statt alle gleichzeitig bei einem sich erholenden Server einzutreffen. Retry-Aftergibt es in zwei Formen, als Verzögerung in Sekunden oder als HTTP-Datum. Ein Poller muss beide parsen und auf Backoff zurückfallen, wenn der Header fehlt oder nicht geparst werden kann.- Browser drosseln Timer in Hintergrund-Tabs, doch die Drosselung verlangsamt einen Poller nur. Erst die Prüfung von
document.hiddenstoppt ihn tatsächlich. - Wird das letzte
ETaginIf-None-Matchmitgesendet, kann der Server mit304 Not Modifiedohne Body antworten, wenn sich nichts geändert hat.
Welche vier typischen Fehler gibt es beim API-Polling?
Naives API-Polling scheitert meist auf vier Arten: Anfragen überlappen sich, das Polling hört nie auf, Fehler führen dazu, dass der Poller den Server noch stärker belastet, und ausgeblendete Tabs pollen weiter. Alle vier Fehler stecken in diesem Snippet:
setInterval(async () => {
const res = await fetch('/api/jobs/42');
render(await res.json());
}, 2000); // overlaps when slow, never stops, ignores errors and hidden tabs
- Überlappung.
setIntervalfeuert nach einem festen Takt und nicht erst, wenn die vorherige Antwort eingetroffen ist. Braucht der Endpunkt länger als das Intervall, stauen sich die Anfragen. - Kein Ausstieg. Nichts beendet die Schleife, nachdem der Job abgeschlossen ist. Sie fragt also weiter ein Ergebnis ab, das sich nie mehr ändern wird.
- Kein Backoff. Liefert der Server 500er-Fehler, wiederholen alle Clients ihre Anfragen mit voller Geschwindigkeit. Das erhöht die Last genau in dem Moment, in dem der Server sie am wenigsten verkraftet.
- Ausgeblendete Tabs. Ein Tab, den niemand ansieht, pollt weiter.
Wie verkettet man setTimeout und stoppt bei einer Abschlussbedingung?
Wenn Sie den nächsten Poll erst planen, nachdem die aktuelle Antwort verarbeitet wurde, ist nie mehr als eine Anfrage gleichzeitig unterwegs. Genau das leistet eine async-Schleife mit einer Pause zwischen den Iterationen. Sie endet, sobald der Job einen Endzustand erreicht:
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');
Die Abschlussbedingung muss neben dem Erfolg auch endgültige Fehlschläge abdecken. Wenn Sie nur auf 'done' prüfen, wird ein Job, der mit 'failed' endet, endlos weiter abgefragt.
Wie bricht man einen Poller mit AbortController ab?
Einen Poller abzubrechen bedeutet, zwei Dinge zu stoppen: die laufende Anfrage und den wartenden Timer. Ein an fetch übergebenes AbortController-Signal erledigt die Anfrage, wirkt sich aber nicht auf einen bereits eingeplanten setTimeout aus. Deshalb muss auch die Sleep-Funktion auf das Signal reagieren:
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 });
});
}
Der Aufruf von controller.abort() weist das gerade ausstehende Promise zurück, egal ob es sich um fetch oder sleep handelt, und die Schleife wird verlassen. Der Abort-Listener wird entfernt, sobald der Timer feuert. So fügt ein lang laufender Poll, der ein einziges Signal wiederverwendet, nicht bei jeder Iteration einen neuen Listener hinzu.
Wie implementiert man exponentiellen Backoff mit Jitter und Obergrenze?
Wenn eine Anfrage fehlschlägt, warten Sie random(0, min(cap, base * 2 ** attempt)), bevor Sie es erneut versuchen. Das ist die „Full Jitter“-Strategie aus der AWS-Analyse Exponential Backoff and Jitter. Exponentieller Backoff mit Obergrenze, aber ohne Zufallsanteil, führt weiterhin dazu, dass Clients in synchronisierten Clustern erneut anfragen. Wird jede Verzögerung zufällig zwischen null und der exponentiellen Obergrenze gewählt, lösen sich diese Cluster auf.
const backoff = (attempt, base = 1000, cap = 30000) =>
Math.random() * Math.min(cap, base * 2 ** attempt);
Diese einzeilige backoff-Funktion entspricht der Full-Jitter-Funktion im Quellcode des AWS-Backoff-Simulators. In der AWS-Simulation von 2015 mit 100 Clients, die um dieselbe Ressource konkurrierten, reduzierte Jitter die Gesamtzahl der Aufrufe um mehr als die Hälfte. Beachten Sie, dass die Simulation konkurrierende Schreibzugriffe modellierte, kein lesendes Polling. Betrachten Sie den Wert daher als AWS-Ergebnis für genau dieses Szenario.
Erhöhen Sie attempt in der Schleife nach jedem Fehlschlag und setzen Sie den Wert nach jedem Erfolg auf 0 zurück. Ohne das Zurücksetzen würde ein einzelner kurzer Aussetzer zu Beginn dazu führen, dass jeder spätere Fehlschlag mit einer langen Verzögerung startet. Serverseitige Worker haben dasselbe Retry-Problem, und Job-Queues lösen es mit ähnlichen Retry-Richtlinien.
Wie sollte ein Poller mit 429, 503 und Retry-After umgehen?
Sendet der Server eine 429- oder 503-Antwort mit einem Retry-After-Header, warten Sie so lange, wie der Header vorgibt. Ihr eigener Backoff dient als Fallback, falls der Header fehlt. Der Retry-After-Header kommt in einer von zwei Formen vor: als ganze Zahl von Sekunden, die nach Eintreffen der Antwort abzuwarten sind, oder als HTTP-Datum, ab dem ein erneuter Versuch erlaubt ist. RFC 9110 definiert beide Formen, und MDN beschreibt die Verwendung zusammen mit 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);
}
Die Datumsform wird mit der Uhr des Clients verglichen. Weichen die Uhren voneinander ab und liegt das Datum bereits in der Vergangenheit, wird die Verzögerung auf null begrenzt. Gibt Date.parse NaN zurück, liefert die Funktion null, und der Aufrufer greift stattdessen auf Backoff zurück. Die Obergrenze verhindert, dass ein falsch konfigurierter Header den Poller stundenlang lahmlegt.
Bei Cross-Origin-Anfragen kann JavaScript Retry-After nur lesen, wenn der Server den Header in Access-Control-Expose-Headers aufführt, denn er gehört nicht zu den CORS-safelisted Response-Headern. Ist er nicht freigegeben, liefert res.headers.get() den Wert null, und der Poller fällt ohne jede Fehlermeldung auf einfachen Backoff zurück.
Wie pausiert man das Polling, wenn der Tab ausgeblendet ist?
Browser drosseln Timer in Hintergrund-Tabs, doch ein gedrosselter Poller sendet weiterhin Anfragen, nur seltener. Um ihn wirklich anzuhalten, prüfen Sie document.hidden und lauschen auf visibilitychange. Beides ist Teil der Page Visibility API. Diese Hilfsfunktion löst sofort auf, wenn die Seite sichtbar ist, und wartet andernfalls, bis sie sichtbar wird:
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 });
});
}
Wenn Sie die Funktion zu Beginn jeder Iteration mit await aufrufen, sendet ein ausgeblendeter Tab keine Anfragen. Die erste Anfrage nach der Rückkehr des Nutzers geht sofort raus. Eine Einschränkung gibt es: Läuft bereits eine lange Backoff-Pause, wenn der Tab wieder sichtbar wird, wartet der Poller zunächst deren Ende ab.
ETags nutzen und alles zusammenführen
Wenn Sie das letzte ETag in einem If-None-Match-Header zurücksenden, kann der Server mit 304 Not Modified ohne Body antworten, sofern sich die Ressource nicht geändert hat. Setzen Sie If-None-Match selbst und verwenden dabei den Standard-Cache-Modus, umgeht die Fetch-Spezifikation den HTTP-Cache. Ihr Code erhält die 304-Antwort also direkt und muss sie selbst verarbeiten. Bei Cross-Origin-Anfragen sind zwei CORS-Details zu beachten. Erstens muss der Server ETag freigeben. Zweitens löst das Senden von If-None-Match einen Preflight aus, da es sich nicht um einen CORS-safelisted Request-Header handelt. Der Server muss den Header daher auch in Access-Control-Allow-Headers aufführen.
Diese Tabelle zeigt, wie der fertige Poller auf die einzelnen Ergebnisse reagiert (die Bedeutung der Statuscodes finden Sie in der oben verlinkten Referenz zu HTTP-Statuscodes):
| Antwort | Aktion des Pollers | Nächste Verzögerung |
|---|---|---|
| 200 | UI aktualisieren, Abschluss prüfen, Versuche zurücksetzen | interval |
| 304 | Keine Aktualisierung, Versuche zurücksetzen | interval |
| 429 / 503 | Versuche erhöhen | Retry-After, sonst Backoff |
| Andere 5xx / Netzwerkfehler | Versuche erhöhen | Backoff |
| Andere 4xx | Fehler werfen und anzeigen | Keine |
| Abbruch | Erneut werfen und beenden | Keine |
Die folgende Funktion verwendet sleep, backoff, retryAfterMs und whenVisible aus den vorherigen Abschnitten. Die Verzweigung nach Statuscode liegt außerhalb des try/catch-Blocks, damit ein 4xx-Fehler nie fälschlich als Netzwerkfehler behandelt und wiederholt wird.
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);
}
}
Da attempt per Präinkrement erhöht wird, verwendet bereits der erste erneute Versuch attempt = 1, wodurch diese Verzögerung auf maximal 2 Sekunden begrenzt ist.
Kapselung in einem React-Hook
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 führt das Effect-Cleanup beim Unmount aus sowie vor dem erneuten Ausführen des Effects, wenn sich url ändert. Der alte Poller wird also immer abgebrochen. Definieren Sie isDone außerhalb der Komponente, damit die Referenz stabil bleibt. Der Status 'error' ermöglicht es der UI, einen Zustand wie „Wir haben die Abfrage gestoppt. Erneut versuchen?“ darzustellen. Session Replays von Pollern, die ihren Zustand nicht nach außen sichtbar machen, zeigen häufig einen Spinner, der nie verschwindet, während die zugrunde liegenden Netzwerkanfragen längst gestoppt sind oder dauerhaft fehlschlagen.
Fazit
Ein Poller, der Ihren Server nicht überlastet, beruht auf wenigen Regeln: jeweils nur eine Anfrage, eine klare Abbruchbedingung, Backoff mit Jitter, der nach einem Erfolg zurückgesetzt wird, Beachtung von Retry-After, keine Anfragen aus ausgeblendeten Tabs und Conditional Requests, damit unveränderte Daten so gut wie nichts kosten. Ersetzen Sie Ihr setInterval durch die obige poll-Funktion und prüfen Sie anschließend, ob Ihr Server ETag und Retry-After zurückgibt und beide für Cross-Origin-Clients freigibt. Fehlt diese serverseitige Unterstützung, fällt der Client-Code stillschweigend auf einfachen Backoff und vollständige Antworten zurück.
FAQs
Pausiert der Poller, wenn der Nutzer zu einem anderen Anwendungsfenster wechselt?
Nicht immer. Eine Seite gilt als ausgeblendet, wenn ihr Tab in den Hintergrund wechselt, das Browserfenster minimiert wird oder die Bildschirmsperre des Betriebssystems aktiv wird. Eine Seite, die teilweise auf dem Bildschirm sichtbar bleibt, behält den visibilityState 'visible', und der Fokusverlust an ein anderes Fenster blendet sie nicht aus. In diesem Fall läuft der Poller weiter. Lauschen Sie auf die Events blur und focus, wenn Sie auch pausieren möchten, sobald das Fenster den Fokus verliert.
Was ist der Unterschied zwischen Full Jitter, Equal Jitter und Decorrelated Jitter?
Jede Variante randomisiert exponentiellen Backoff mit Obergrenze auf eine andere Weise. Full Jitter wählt eine Verzögerung zwischen null und min(cap, base * 2 ** attempt). Equal Jitter hält die Hälfte dieser Obergrenze fest und randomisiert die andere Hälfte. Decorrelated Jitter wählt eine Verzögerung zwischen base und dem Dreifachen der vorherigen Verzögerung, ebenfalls begrenzt. In der AWS-Simulation schnitt Equal Jitter von den drei Jitter-Varianten am schwächsten ab. Full Jitter verursachte weniger Arbeit als Decorrelated Jitter, dauerte aber etwas länger.
Kann ich diesen Poller in Node.js verwenden?
Ja, mit einer Änderung. Node.js 18 und neuer stellen ein globales fetch bereit, das ab Node.js 21 als stabil gilt, sowie einen globalen AbortController. Node.js hat kein document-Objekt, daher wirft whenVisible einen ReferenceError. Ergänzen Sie eine typeof-document-Prüfung, die außerhalb des Browsers sofort auflöst. Backoff, Retry-After und ETag-Verarbeitung funktionieren unverändert. Node.js erzwingt kein CORS, daher sind alle Response-Header lesbar.
Warum liefert fetch nie eine 304-Antwort, wenn ich If-None-Match nicht selbst setze?
Der HTTP-Cache des Browsers übernimmt die Revalidierung intern. Muss eine gespeicherte Antwort revalidiert werden, fügt der Browser die Conditional-Header selbst hinzu. Antwortet der Server mit 304, übergibt fetch Ihrem Code die gecachte Antwort mit Status 200 und vollständigem Body, sodass Sie nicht erkennen können, dass die Anfrage konditional war. Erst das manuelle Setzen von If-None-Match macht die 304-Antwort für JavaScript sichtbar.