einen eigenen useFetchWithRetry-Hook für instabile APIs bauen
Instabile APIs, kurzzeitige Netzwerkausfälle oder überlastete Server führen dazu, dass ein einzelner fehlgeschlagener Request nicht automatisch einen echten Fehler bedeutet. Ein eigener useFetchWithRetry-Hook mit exponentiellem Backoff wiederholt fehlgeschlagene Anfragen mit wachsender Wartezeit, ohne dass dafür zwingend eine zusätzliche Bibliothek nötig ist.
Inhaltsverzeichnis
- 1. Warum ein einzelner fehlgeschlagener Request nicht das Ende bedeutet
- 2. Wie exponentielles Backoff mathematisch funktioniert
- 3. Das Grundgerüst des useFetchWithRetry-Hooks
- 4. Nicht jeder Fehler verdient einen Retry
- 5. Abgrenzung zu TanStack Querys eingebautem Retry
- 6. Manuellen Retry-Button für den Nutzer anbieten
- 7. Backoff-Logik zuverlässig testen
- 8. UX-Aspekte: Nutzer während des Retries nicht im Unklaren lassen
- 9. Fazit: Eigene Lösung versus Bibliothek
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein einzelner fehlgeschlagener Request nicht das Ende bedeutet
Nicht jeder fehlgeschlagene Netzwerk-Request ist ein dauerhafter Fehler. Ein kurzzeitiger Verbindungsabbruch beim Mobilfunknetz, ein Server, der gerade neu deployt wird und für wenige Sekunden nicht erreichbar ist, oder ein überlasteter Endpunkt, der mit 503 Service Unavailable antwortet, sind typische, vorübergehende Zustände. Zeigt eine Anwendung bei jedem dieser Fälle sofort eine Fehlermeldung an, wirkt sie fragiler als die zugrunde liegende Infrastruktur tatsächlich ist, und Nutzer müssen die Aktion manuell erneut auslösen, obwohl ein automatischer zweiter Versuch oft ausgereicht hätte.
Ein Retry-Mechanismus adressiert genau dieses Problem, indem er fehlgeschlagene Requests automatisch erneut versucht, bevor er dem Nutzer einen endgültigen Fehler präsentiert. Entscheidend ist dabei, nicht sofort und ohne Pause erneut zu senden, denn das würde einen bereits überlasteten Server zusätzlich belasten und im schlimmsten Fall die Wiederherstellung verzögern. Genau hier setzt exponentielles Backoff an: Die Wartezeit zwischen den Versuchen wächst mit jedem fehlgeschlagenen Versuch, sodass die Anwendung dem Server Zeit gibt, sich zu erholen, statt ihn mit einer Flut an sofortigen Wiederholungen zu bombardieren.
2. Wie exponentielles Backoff mathematisch funktioniert
Die Grundformel für exponentielles Backoff ist denkbar einfach: Die Wartezeit vor dem n-ten Wiederholungsversuch berechnet sich als baseDelay * 2^(n-1). Bei einer Basiswartezeit von 500 Millisekunden ergibt das für den ersten Versuch 500ms, für den zweiten 1000ms, für den dritten 2000ms und für den vierten 4000ms. Diese exponentielle Wachstumsrate sorgt dafür, dass die Belastung des Servers bei anhaltenden Problemen schnell abnimmt, während frühe, kurze Aussetzer trotzdem zügig kompensiert werden.
In der Praxis ergänzt man diese Basisformel meist um zwei weitere Faktoren: eine Obergrenze für die maximale Wartezeit, damit die Verzögerung nicht ins Unendliche wächst, und einen zufälligen Jitter-Anteil, der die berechnete Wartezeit leicht variiert. Der Jitter ist wichtig, weil ohne ihn viele gleichzeitig fehlgeschlagene Clients exakt zum selben Zeitpunkt erneut anfragen würden, was zu synchronisierten Lastspitzen führt, dem sogenannten Thundering-Herd-Problem. Ein zufälliger Anteil von etwa 20 bis 30 Prozent der berechneten Wartezeit verteilt die erneuten Anfragen zeitlich und entschärft dieses Problem spürbar.
function calculateBackoffDelay(attempt, baseDelay = 500, maxDelay = 10000) {
const exponentialDelay = baseDelay * 2 ** (attempt - 1);
const cappedDelay = Math.min(exponentialDelay, maxDelay);
const jitter = cappedDelay * 0.25 * Math.random();
return Math.round(cappedDelay + jitter);
}
// Beispielwerte: 500-625ms, 1000-1250ms, 2000-2500ms, 4000-5000ms, ...
3. Das Grundgerüst des useFetchWithRetry-Hooks
Der Hook kapselt drei Verantwortlichkeiten: das eigentliche Ausführen des Fetch-Requests, die Retry-Logik mit Backoff-Berechnung, und die Verwaltung des sichtbaren Zustands für Ladeanzeige, Fehler und Ergebnis. Nach außen bleibt die Schnittstelle bewusst einfach gehalten, eine Komponente ruft useFetchWithRetry(url, options) auf und erhält data, loading, error sowie die Anzahl der bereits unternommenen Versuche zurück, ohne sich um die Details der Wiederholungslogik kümmern zu müssen.
Intern nutzt der Hook eine rekursive oder schleifenbasierte Funktion, die bei einem Fehlschlag prüft, ob die maximale Anzahl an Versuchen bereits erreicht ist. Ist das nicht der Fall, wartet sie über setTimeout die berechnete Backoff-Zeit ab und versucht den Request erneut. Ist die maximale Anzahl erreicht, wird der letzte Fehler final an den State weitergereicht, damit die Komponente eine aussagekräftige Fehlermeldung anzeigen kann.
function useFetchWithRetry(url, { maxRetries = 3, baseDelay = 500 } = {}) {
const [state, setState] = useState({ data: null, loading: true, error: null, attempt: 0 });
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function fetchWithRetry(attempt) {
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!cancelled) setState({ data, loading: false, error: null, attempt });
} catch (err) {
if (err.name === "AbortError" || cancelled) return;
if (attempt >= maxRetries) {
setState({ data: null, loading: false, error: err, attempt });
return;
}
const delay = calculateBackoffDelay(attempt + 1, baseDelay);
setState((prev) => ({ ...prev, attempt: attempt + 1 }));
setTimeout(() => {
if (!cancelled) fetchWithRetry(attempt + 1);
}, delay);
}
}
setState({ data: null, loading: true, error: null, attempt: 0 });
fetchWithRetry(0);
return () => {
cancelled = true;
controller.abort();
};
}, [url, maxRetries, baseDelay]);
return state;
}
4. Nicht jeder Fehler verdient einen Retry
Ein häufiger Anfängerfehler ist, jeden fehlgeschlagenen Request pauschal zu wiederholen, unabhängig davon, was die eigentliche Fehlerursache war. Ein 401 Unauthorized oder 403 Forbidden wird sich bei einer sofortigen Wiederholung mit denselben Zugangsdaten nicht plötzlich in einen Erfolg verwandeln, ebenso wenig wie ein 400 Bad Request, der auf fehlerhafte Eingabedaten hinweist. Solche Client-Fehler sind strukturell und werden durch Wiederholung nicht behoben, ein Retry verschwendet hier nur Zeit und verzögert die für den Nutzer eigentlich sofort sichtbare, korrekte Fehlermeldung.
Sinnvoll ist ein Retry hingegen bei Server-Fehlern wie 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable und 504 Gateway Timeout, sowie bei reinen Netzwerkfehlern, bei denen die Anfrage den Server gar nicht erst erreicht hat. Ein robuster Hook unterscheidet deshalb zwischen retry-fähigen und nicht retry-fähigen Fehlern und bricht bei Client-Fehlern sofort ab, statt unnötig mehrere Sekunden zu warten, bevor der Nutzer überhaupt eine Rückmeldung bekommt.
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
function isRetryableError(error) {
if (error instanceof TypeError) return true; // Netzwerkfehler, z.B. offline
if (error.status) return RETRYABLE_STATUS_CODES.has(error.status);
return false;
}
// Im catch-Block der fetchWithRetry-Funktion:
// if (!isRetryableError(err) || attempt >= maxRetries) {
// setState({ data: null, loading: false, error: err, attempt });
// return;
// }
5. Abgrenzung zu TanStack Querys eingebautem Retry
TanStack Query bringt eine Retry-Logik mit exponentiellem Backoff bereits standardmäßig mit, konfigurierbar über die Optionen retry und retryDelay beim Erstellen einer Query. Wer TanStack Query bereits im Projekt einsetzt, etwa für Caching, Deduplizierung paralleler Anfragen oder automatisches Refetching bei Fokuswechsel, sollte diese eingebaute Funktionalität nutzen, statt Retry-Logik parallel selbst zu bauen. Die Bibliothek deckt zusätzlich Randfälle ab wie das Zusammenspiel von Retry mit Query-Invalidierung oder Offline-Erkennung, die in einer eigenen Implementierung leicht übersehen werden.
Ein eigener useFetchWithRetry-Hook ist dann sinnvoll, wenn ein Projekt bewusst ohne Datenabruf-Bibliothek arbeitet, etwa weil der Funktionsumfang von Caching und Query-Invalidierung nicht benötigt wird, das Bundle möglichst klein bleiben soll, oder nur ein einzelner, isolierter Anwendungsfall wie ein kritischer Formular-Submit robuster gemacht werden muss. Für ein einzelnes, gut abgegrenztes Problem ist eine schlanke Eigenlösung oft wartungsfreundlicher als eine zusätzliche Abhängigkeit, deren volle Funktionsbreite ungenutzt bliebe.
6. Manuellen Retry-Button für den Nutzer anbieten
Neben dem automatischen Retry im Hintergrund ist es sinnvoll, dem Nutzer nach Erschöpfung aller automatischen Versuche eine manuelle Möglichkeit zum erneuten Versuch zu geben. Das ist besonders wichtig, weil ein automatischer Retry mit begrenzter Anzahl an Versuchen irgendwann aufgeben muss, ein Nutzer aber durchaus wissen kann, dass sich die Ursache des Problems inzwischen geändert hat, etwa weil er wieder WLAN-Empfang hat oder ein bekannter Serverausfall behoben wurde.
Der Hook sollte deshalb eine retry-Funktion zurückgeben, die den internen Zähler zurücksetzt und den Fetch-Vorgang von vorne beginnt. Diese Funktion lässt sich direkt an einen sichtbaren Button binden, der nur im Fehlerzustand angezeigt wird. Wichtig ist, bei einem manuell ausgelösten Retry den Versuchszähler wirklich auf null zurückzusetzen, statt ihn fortzuführen, denn ein manueller Klick ist ein bewusster neuer Versuch des Nutzers und sollte nicht sofort wieder gegen das Limit der automatischen Versuche laufen.
function useFetchWithRetry(url, options = {}) {
const [resetKey, setResetKey] = useState(0);
// ... state und fetchWithRetry wie zuvor, useEffect haengt zusaetzlich von resetKey ab
const retry = useCallback(() => setResetKey((k) => k + 1), []);
// ... return { ...state, retry };
}
function ProductFeed() {
const { data, loading, error, retry } = useFetchWithRetry("/api/products");
if (loading) return <p>Laedt...</p>;
if (error) {
return (
<div role="alert">
<p>Produkte konnten nicht geladen werden.</p>
<button onClick={retry}>Erneut versuchen</button>
</div>
);
}
return <ProductList items={data} />;
}
7. Backoff-Logik zuverlässig testen
Retry-Logik mit echten Wartezeiten zu testen wäre in einer Testsuite unpraktikabel langsam, weshalb sich Jests Fake-Timer anbieten, um die Zeit künstlich vorzuspulen, ohne tatsächlich zu warten. Man mockt fetch so, dass es bei den ersten Aufrufen fehlschlägt und erst beim letzten Versuch erfolgreich antwortet, aktiviert jest.useFakeTimers(), und spult die Zeit nach jedem erwarteten Fehlschlag mit jest.advanceTimersByTimeAsync() um die berechnete Backoff-Dauer vor.
Ein zweiter wichtiger Test prüft, dass nicht retry-fähige Fehler wie ein 401 sofort ohne Wiederholung durchgereicht werden. Dieser Test stellt sicher, dass die Unterscheidung zwischen retry-fähigen und nicht retry-fähigen Fehlern tatsächlich greift und nicht versehentlich jeder Fehler pauschal mehrfach wiederholt wird, was unnötige Wartezeit für den Nutzer bei eindeutigen Client-Fehlern bedeuten würde.
test("gibt nach erfolgreichem dritten Versuch Daten zurueck", async () => {
jest.useFakeTimers();
global.fetch = jest
.fn()
.mockRejectedValueOnce(new TypeError("network error"))
.mockRejectedValueOnce(new TypeError("network error"))
.mockResolvedValueOnce({ ok: true, json: async () => ({ items: [] }) });
const { result } = renderHook(() => useFetchWithRetry("/api/products"));
await jest.advanceTimersByTimeAsync(1000);
await jest.advanceTimersByTimeAsync(2500);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({ items: [] });
expect(global.fetch).toHaveBeenCalledTimes(3);
jest.useRealTimers();
});
8. UX-Aspekte: Nutzer während des Retries nicht im Unklaren lassen
Ein technisch korrekter Retry-Mechanismus kann trotzdem eine schlechte Nutzererfahrung erzeugen, wenn der Nutzer während der Wiederholungsversuche nur einen unveränderten Ladeindikator sieht. Bei mehreren Sekunden Gesamtwartezeit über alle Versuche hinweg wirkt eine Anwendung ohne erkennbares Feedback träge oder sogar eingefroren. Der zurückgegebene attempt-Zähler des Hooks lässt sich nutzen, um dem Nutzer transparent zu zeigen, dass gerade ein erneuter Versuch läuft, etwa mit einem Text wie "Versuch 2 von 3".
Bei sehr langen maximalen Backoff-Zeiten lohnt es sich zusätzlich, dem Nutzer nach dem ersten fehlgeschlagenen Versuch bereits die Möglichkeit zu geben, manuell abzubrechen oder sofort einen neuen Versuch zu erzwingen, statt ihn zum passiven Warten auf den nächsten automatischen Versuch zu zwingen. Diese Transparenz verwandelt einen technisch notwendigen Mechanismus in eine nachvollziehbare, vertrauenswürdige Nutzererfahrung statt in eine Blackbox.
9. Fazit: Eigene Lösung versus Bibliothek
Ein eigener useFetchWithRetry-Hook mit exponentiellem Backoff ist eine überschaubare, gut wartbare Lösung für Projekte, die gezielt ohne zusätzliche Datenabruf-Bibliothek arbeiten oder nur einen einzelnen, klar abgegrenzten Anwendungsfall robuster machen wollen. Die Kernbausteine, Backoff-Berechnung mit Jitter, Unterscheidung zwischen retry-fähigen und nicht retry-fähigen Fehlern, sowie ein manueller Retry für den Nutzer, lassen sich mit überschaubarem Aufwand implementieren und gründlich testen.
Sobald ein Projekt jedoch bereits TanStack Query oder eine vergleichbare Bibliothek einsetzt, ist deren eingebaute Retry-Logik in aller Regel die bessere Wahl, weil sie zusätzliche Randfälle abdeckt und nicht redundant parallel gepflegt werden muss. Die Entscheidung zwischen Eigenbau und Bibliothek sollte sich also weniger an der grundsätzlichen technischen Machbarkeit orientieren, die in beiden Fällen gegeben ist, sondern daran, ob eine Datenabruf-Bibliothek im Projekt ohnehin schon vorhanden oder sinnvoll ist.
| Aspekt | Eigener useFetchWithRetry-Hook | TanStack Query | Empfehlung |
|---|---|---|---|
| Zusaetzliche Abhaengigkeit | Nein |
Ja |
Bei kleinem Bundle eigener Hook |
| Caching und Deduplizierung | Nein |
Ja |
TanStack Query bei Caching-Bedarf |
| Exponential Backoff mit Jitter | Selbst implementiert | Eingebaut |
Beide moeglich |
| Randfaelle wie Offline-Erkennung | Muss selbst abgedeckt werden | Eingebaut |
TanStack Query robuster |
| Geeignet fuer | Einzelnen Anwendungsfall | Gesamte Datenschicht der App | Je nach Projektgroesse |
Mironsoft
React-Architektur, Performance und Magento-Frontend-Integration
React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?
Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.
Performance-Audit
Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.
State-Architektur
Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.
Magento-Integration
GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.
10. Zusammenfassung
Fetch-Retry mit Exponential Backoff: Das Wichtigste auf einen Blick
Grundidee
Fehlgeschlagene Requests mit wachsender Wartezeit automatisch wiederholen.
Backoff-Formel
baseDelay mal 2 hoch (Versuch minus 1), plus Jitter, mit Obergrenze.
Wichtige Regel
Nur Server- und Netzwerkfehler wiederholen, Client-Fehler sofort melden.
Bibliotheks-Grenze
Bei vorhandenem TanStack Query dessen eingebauten Retry nutzen.