Fetch-Retry mit Exponential Backoff in React Hooks | Mironsoft
AI generated
{ }
React 19 · Hooks · Datenabruf
Fetch-Retry mit Exponential Backoff
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.

15 Min. Lesezeit Custom Hook Exponential Backoff Fehlerbehandlung

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.

11. FAQ: Fetch-Retry mit Exponential Backoff: Das Wichtigste auf einen Blick

1Was ist exponentielles Backoff?
Exponentielles Backoff ist eine Retry-Strategie, bei der die Wartezeit zwischen aufeinanderfolgenden Wiederholungsversuchen mit jedem Fehlschlag exponentiell wächst, üblicherweise nach der Formel baseDelay mal 2 hoch (Versuch minus 1).
2Warum braucht Backoff zusätzlich einen Jitter-Anteil?
Ohne Jitter würden viele gleichzeitig fehlgeschlagene Clients exakt zum selben Zeitpunkt erneut anfragen, was zu synchronisierten Lastspitzen führt. Ein zufälliger Anteil verteilt die Wiederholungen zeitlich und entschärft dieses Thundering-Herd-Problem.
3Sollte jeder fehlgeschlagene Request wiederholt werden?
Nein, Client-Fehler wie 401 oder 400 werden durch Wiederholung nicht behoben, da sie strukturelle Ursachen wie fehlende Berechtigung oder falsche Eingabedaten haben. Nur Server- und Netzwerkfehler sollten wiederholt werden.
4Wie unterscheide ich retry-fähige von nicht retry-fähigen Fehlern?
Man prüft den HTTP-Statuscode gegen eine Liste retry-fähiger Codes wie 408, 429, 500, 502, 503 und 504, sowie ob es sich um einen reinen Netzwerkfehler handelt, bei dem der Server gar nicht erreicht wurde.
5Wann sollte ich einen eigenen useFetchWithRetry-Hook statt TanStack Query verwenden?
Wenn ein Projekt bewusst ohne Datenabruf-Bibliothek arbeitet, das Bundle klein bleiben soll, oder nur ein einzelner, isolierter Anwendungsfall robuster gemacht werden muss, ohne dass Caching oder Query-Invalidierung benötigt werden.
6Deckt TanStack Query mehr Randfälle ab als eine Eigenlösung?
Ja, TanStack Query berücksichtigt zusätzlich Aspekte wie Offline-Erkennung und das Zusammenspiel von Retry mit Query-Invalidierung, die in einer schlanken Eigenlösung leicht übersehen werden.
7Wie teste ich Backoff-Zeiten, ohne echte Sekunden zu warten?
Mit Jests Fake-Timern lässt sich die Zeit künstlich vorspulen, indem man jest.useFakeTimers() aktiviert und die Zeit nach jedem erwarteten Fehlschlag mit jest.advanceTimersByTimeAsync() um die berechnete Backoff-Dauer vorspult.
8Warum ist ein manueller Retry-Button trotz automatischem Retry sinnvoll?
Weil ein automatischer Retry mit begrenzter Anzahl an Versuchen irgendwann aufgeben muss, ein Nutzer aber wissen kann, dass sich die Fehlerursache inzwischen geändert hat, etwa nach wiederhergestelltem WLAN-Empfang.
9Sollte der Versuchszähler bei manuellem Retry fortgeführt werden?
Nein, ein manuell ausgelöster Retry sollte den Versuchszähler auf null zurücksetzen, da es sich um einen bewussten neuen Versuch des Nutzers handelt und dieser nicht sofort wieder gegen das automatische Limit laufen soll.
10Wie verhindere ich, dass Nutzer den Retry-Vorgang als eingefroren wahrnehmen?
Der Hook sollte den aktuellen Versuchszähler zurückgeben, damit die Komponente transparent anzeigen kann, dass gerade ein erneuter Versuch läuft, etwa mit einem sichtbaren Hinweis wie Versuch 2 von 3.