JS
() =>
JavaScript · Fetch API · HTTP · REST
JavaScript Fetch API Mastery
HTTP-Requests professionell umsetzen

Die Fetch API ist mehr als ein Ersatz für XMLHttpRequest. Wer Timeouts, Retry-Logik, Streaming-Responses, Authentifizierung und Cache-Strategien beherrscht, schreibt HTTP-Kommunikation, die in realen Anwendungen zuverlässig und wartbar bleibt – ohne externe Bibliotheken.

15 Min. Lesezeit AbortController · Retry · Streaming · Auth · Caching Browser · Node.js 18+ · Deno · Bun

1. Warum Fetch API statt axios oder jQuery?

Die Fetch API ist seit 2015 fester Bestandteil der Browser-Plattform und seit Node.js 18 auch serverseitig nativ verfügbar. Lange galt axios als bevorzugte Wahl wegen seines einfacheren Fehlermodells und der automatischen JSON-Serialisierung – Gründe, die durch gezielte Wrapper-Funktionen obsolet werden. Wer die Fetch API direkt beherrscht, vermeidet eine externe Abhängigkeit, nutzt native Browser-Features wie die Cache API und den Service Worker und profitiert von Request-Objekten, die sich als First-Class-Citizens durch den gesamten Web-Stack bewegen.

Der entscheidende Unterschied zwischen einem Entwickler, der die Fetch API oberflächlich kennt, und einem, der sie wirklich beherrscht, liegt in drei Bereichen: korrekter Fehlerbehandlung, Timeout-Management und der Integration mit Browser-APIs wie dem Service Worker. Ein einfaches fetch(url) löst keine Exception bei HTTP-Fehlercodes wie 404 oder 500 – die Promise wird erfüllt, nicht abgewiesen. Wer das nicht weiß, schreibt Clients, die Serverfehler stillschweigend übersehen. Diese fünfzehn Abschnitte bauen die Fetch API-Kenntnisse systematisch von Grundlagen bis zu produktionstauglichen Patterns auf.

2. Request-Konfiguration: Methoden, Header und Body

Jeder Fetch API-Aufruf akzeptiert als zweites Argument ein Request-Init-Objekt, das Methode, Header, Body, Credentials und Cache-Verhalten steuert. Die empfohlene Vorgehensweise für wiederholbare, konfigurierbare Requests ist die Verwendung des Request-Konstruktors, der ein konfigurierbares Objekt erzeugt, das auch in Service Workern oder der Cache API verwendet werden kann. Die Trennung zwischen URL-Konstruktion und Request-Konfiguration macht den Code testbarer, weil Request-Objekte inspektierbar sind.

Header werden über das Headers-Objekt verwaltet, das eine ergonomische API für das Setzen, Lesen und Löschen von HTTP-Headern bietet. Wichtig: Browser blockieren bestimmte Header aus Sicherheitsgründen – Origin, Cookie und Host können nicht programmatisch gesetzt werden. Für JSON-APIs ist Content-Type: application/json obligatorisch, sonst interpretiert der Server den Body möglicherweise als text/plain. Das Credentials-Feld steuert, ob Cookies und HTTP-Auth-Informationen mitgesendet werden: same-origin ist der sichere Standard, include ist für Cross-Origin-Requests mit Cookies nötig, aber nur in Kombination mit korrekten CORS-Headers auf dem Server.


// Base fetch wrapper — reusable configuration pattern
const API_BASE = 'https://api.mironsoft.de/v1';

/**
 * Creates a configured Request object for the given endpoint.
 * @param {string} path - API path relative to base URL
 * @param {RequestInit} options - Fetch options to merge
 * @returns {Request}
 */
function createRequest(path, options = {}) {
  const headers = new Headers({
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'X-Client-Version': '2.0.0',
    ...options.headers,
  });

  return new Request(`${API_BASE}${path}`, {
    method: 'GET',
    credentials: 'same-origin',
    ...options,
    headers,
  });
}

// POST with JSON body — clean serialization pattern
async function postJson(path, data) {
  const request = createRequest(path, {
    method: 'POST',
    body: JSON.stringify(data),
  });

  const response = await fetch(request);

  // fetch() only rejects on network failure — HTTP errors need explicit check
  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({}));
    throw new Error(`HTTP ${response.status}: ${errorBody.message ?? response.statusText}`);
  }

  return response.json();
}

// Usage
const user = await postJson('/users', { name: 'Max', email: 'max@mironsoft.de' });

3. Fehlerbehandlung: HTTP-Fehler vs. Netzwerkfehler

Das größte Missverständnis bei der Fetch API ist ihr Fehlermodell. Die Promise wird nur bei echten Netzwerkfehlern abgewiesen – wenn der Server gar nicht erreichbar ist, die DNS-Auflösung fehlschlägt oder die Verbindung während des Requests unterbrochen wird. HTTP-Statuscodes wie 400, 401, 403, 404 oder 500 führen nicht zu einem abgelehnten Promise. Die Response ist trotzdem erfüllt, nur mit response.ok === false und dem entsprechenden Statuscode. Wer nach await fetch(url) direkt response.json() aufruft, ohne response.ok zu prüfen, übersieht Serverfehler vollständig.

Ein robustes Fehlermodell unterscheidet daher systematisch: Netzwerkfehler (TypeError), HTTP-Clientfehler (4xx) und HTTP-Serverfehler (5xx). Für Clientfehler ist ein Retry sinnlos – die Anfrage ist grundsätzlich falsch. Serverfehler hingegen können transient sein und rechtfertigen einen Retry-Mechanismus. Für strukturiertes Fehler-Handling empfiehlt sich eine benutzerdefinierte Error-Klasse, die Statuscode, Response-Body und Original-URL enthält, um Fehler im Logging klar zuzuordnen. Das Muster response.json().catch(() => null) verhindert, dass eine defekte JSON-Antwort (wie eine HTML-Fehlerseite) den Fehlerhandler selbst zum Absturz bringt.

4. AbortController: Timeouts und abgebrochene Requests

Die Fetch API hat von sich aus kein Timeout. Ohne explizite Absicherung wartet ein fetch()-Aufruf unbegrenzt auf eine Antwort – in der Praxis problematisch bei langsamen APIs oder blockierten Verbindungen. Der AbortController löst dieses Problem elegant: ein AbortSignal wird dem Request übergeben, und bei controller.abort() wird die Verbindung sofort getrennt und die Promise mit einem AbortError abgelehnt. Ab Chrome 124 und Firefox 124 gibt es zusätzlich AbortSignal.timeout(ms), das einen Signal erzeugt, der nach der angegebenen Zeit automatisch abläuft – ohne manuelles setTimeout.

Wichtig ist das Unterscheiden zwischen einem Timeout-Abbruch und einem vom Benutzer ausgelösten Abbruch. In einer Single-Page-Application muss ein Request abgebrochen werden, wenn der Benutzer zu einer anderen Seite navigiert, bevor die Antwort eingetroffen ist – sonst können veraltete Antworten den UI-State überschreiben. Das Pattern ist: beim Starten eines neuen Requests den vorherigen AbortController signalisieren und einen neuen erzeugen. In React entspricht das dem useEffect-Cleanup. Der AbortError muss im Catch-Block explizit erkannt werden, damit er nicht als Fehler geloggt wird.


// Timeout wrapper using AbortSignal.timeout (modern approach)
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  // AbortSignal.timeout is available since Chrome 124 / Node.js 17.3
  const signal = AbortSignal.timeout(timeoutMs);

  try {
    const response = await fetch(url, { ...options, signal });

    if (!response.ok) {
      throw new HttpError(response.status, url, await response.json().catch(() => null));
    }

    return response;
  } catch (err) {
    if (err.name === 'TimeoutError') {
      throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    if (err.name === 'AbortError') {
      throw new Error(`Request to ${url} was aborted`);
    }
    throw err;
  }
}

// Cancel previous request when new one starts (SPA navigation pattern)
class RequestManager {
  #controller = null;

  async fetch(url, options = {}) {
    // Abort any in-flight request before starting a new one
    this.#controller?.abort();
    this.#controller = new AbortController();

    const signal = this.#controller.signal;

    const response = await fetch(url, { ...options, signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }

  cancel() {
    this.#controller?.abort();
  }
}

const manager = new RequestManager();
// In React: call manager.cancel() in useEffect cleanup

5. Retry-Logik mit exponential Backoff

Transiente Serverfehler – 503 Service Unavailable, 429 Too Many Requests, Netzwerkunterbrechungen – sind in realen Anwendungen unvermeidlich. Eine robuste Fetch API-Implementierung braucht daher eine Retry-Strategie, die nicht stur denselben Request in einer engen Schleife wiederholt, sondern mit exponential Backoff arbeitet: jeder Retry-Versuch wartet doppelt so lange wie der vorherige. Das verhindert, dass ein überlasteter Server durch Massen-Retries weiter belastet wird.

Wichtige Details beim Retry-Design: Nur idempotente HTTP-Methoden (GET, HEAD, PUT, DELETE, OPTIONS) sollten automatisch wiederholt werden. Ein POST-Request ohne Idempotency-Key darf nicht automatisch wiederholt werden, weil der Server ihn möglicherweise bereits verarbeitet hat. Der Statuscode 429 enthält oft einen Retry-After-Header, der die genaue Wartezeit vorgibt – eine gute Implementierung liest diesen Header und wartet entsprechend. Jitter (zufällige Variation der Wartezeit) verhindert, dass mehrere gleichzeitige Clients synchron nach einem Serverausfall wiederholen und so erneut überlasten.


/**
 * Retries a fetch call with exponential backoff and jitter.
 * Only retries on network errors or 5xx/429 status codes.
 * @param {string | Request} input
 * @param {RequestInit} init
 * @param {object} retryOptions
 * @returns {Promise<Response>}
 */
async function fetchWithRetry(input, init = {}, {
  maxRetries = 3,
  baseDelayMs = 300,
  retryableStatuses = new Set([429, 500, 502, 503, 504]),
} = {}) {
  let attempt = 0;

  while (true) {
    try {
      const response = await fetch(input, init);

      // Success or non-retryable client error — return immediately
      if (response.ok || !retryableStatuses.has(response.status)) {
        return response;
      }

      // Read Retry-After header if present (e.g. for 429)
      const retryAfter = response.headers.get('Retry-After');
      const waitMs = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : Math.min(baseDelayMs * 2 ** attempt + Math.random() * 100, 30_000);

      if (attempt >= maxRetries) {
        throw new Error(`HTTP ${response.status} after ${maxRetries} retries`);
      }

      await new Promise(resolve => setTimeout(resolve, waitMs));
      attempt++;
    } catch (err) {
      // Don't retry AbortError or non-network errors
      if (err.name === 'AbortError' || attempt >= maxRetries) throw err;

      const delay = Math.min(baseDelayMs * 2 ** attempt + Math.random() * 100, 30_000);
      await new Promise(resolve => setTimeout(resolve, delay));
      attempt++;
    }
  }
}

6. Authentifizierung: Bearer Tokens und Refresh-Flow

Die meisten APIs erfordern Authentifizierung per Bearer Token im Authorization-Header. Das straightforward umzusetzen ist einfach – die Herausforderung liegt im Token-Refresh-Flow: wenn das Access Token abläuft, muss transparent ein neues angefordert werden, ohne dass der Benutzer die Unterbrechung merkt. Naiv implementiert führt das zu Race Conditions, wenn mehrere gleichzeitige Requests bemerken, dass das Token abgelaufen ist, und alle gleichzeitig einen Refresh-Request senden.

Die Lösung ist ein Singleton-Refresh-Promise: sobald der erste Request ein 401 bemerkt, startet er den Refresh und speichert das Promise. Alle anderen Requests, die ebenfalls ein 401 erhalten, warten auf dasselbe Promise statt eigene Refresh-Requests zu starten. Erst wenn das neue Token vorliegt, werden alle wartenden Requests mit dem neuen Token wiederholt. Dieses Pattern verhindert Thundering-Herd-Probleme beim Token-Ablauf und ist die Grundlage jeder produktionstauglichen JWT-Implementierung. Der Token-Store sollte in einem closured Objekt kapseliert sein, nicht im globalen Scope, um Zugriff von außen zu verhindern.

7. Response Streaming für große Datenmengen

Standardmäßig wartet response.json() darauf, dass der gesamte Response-Body heruntergeladen ist, bevor er geparst wird. Bei großen Datenmengen – Exporte, Log-Streams, KI-generierte Inhalte – ist das nicht akzeptabel. Die Fetch API stellt dafür response.body als ReadableStream bereit. Mit einem TextDecoderStream können eingehende Byte-Chunks in Text umgewandelt und Zeile für Zeile verarbeitet werden, bevor der Download abgeschlossen ist. Dieses Pattern wird auch von Streaming-Chat-APIs wie der OpenAI oder Anthropic API verwendet, die Antworten als Server-Sent Events oder NDJSON streamen.

Für Server-Sent Events gibt es die native EventSource-API, die jedoch keine Custom-Header unterstützt und keine POST-Requests ermöglicht. Mit der Fetch API und ReadableStreams kann man SSE manuell parsen und dabei Bearer Tokens setzen – eine Fähigkeit, die EventSource grundsätzlich fehlt. Der Trick liegt im Parsing: SSE-Daten haben das Format data: {...}\n\n; man akkumuliert eingehende Chunks, teilt an Doppel-Newlines auf und parst jeden Block. Das Abbrechen des Streams per AbortController beendet auch die serverseitige Verbindung.


/**
 * Streams a newline-delimited JSON (NDJSON) response and calls
 * the callback for each parsed line as it arrives.
 * @param {string} url
 * @param {function} onLine - called with each parsed JSON object
 * @param {AbortSignal} signal
 */
async function streamNdjson(url, onLine, signal) {
  const response = await fetch(url, {
    headers: { 'Accept': 'application/x-ndjson' },
    signal,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  if (!response.body) throw new Error('ReadableStream not supported');

  // Pipe through TextDecoderStream to get text chunks
  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();

  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += value;

      // Process all complete lines in the buffer
      let newlineIndex;
      while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, newlineIndex).trim();
        buffer = buffer.slice(newlineIndex + 1);

        if (line) {
          onLine(JSON.parse(line));
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

// Usage: stream a large dataset without waiting for full download
const controller = new AbortController();
await streamNdjson('/api/export/orders', order => {
  console.log('Received order:', order.id);
}, controller.signal);

8. Cache-Strategien mit der Cache API

Die Fetch API und die Cache API sind bewusst als komplementäres Paar entworfen. Der request.cache-Parameter steuert, wie der Browser seinen HTTP-Cache nutzt: no-store umgeht den Cache vollständig, force-cache nutzt einen gecachten Response auch wenn er veraltet ist, no-cache validiert den Cache per konditionellem Request. Im Service Worker lassen sich diese Strategien zu vollständigen Offline-first-Patterns kombinieren: Cache first mit Netzwerk-Fallback für Assets, Network first mit Cache-Fallback für API-Daten.

Für API-Responses ohne Service Worker kann man die Cache API direkt aus dem Hauptthread ansprechen. Das Muster: zunächst den Cache prüfen, bei Cache-Miss den Netzwerk-Request ausführen und die Antwort für spätere Requests im Cache speichern. Response-Objekte können nur einmal gelesen werden (response.json() konsumiert den Body) – daher muss response.clone() aufgerufen werden, bevor man die Antwort sowohl im Cache speichert als auch den Body ausliest. Cache-Keys können erweitert werden, indem man dem URL-Parameter einen Versionierungs-String anhängt, was granularere Cache-Invalidierung ermöglicht.

9. Fetch-Patterns im direkten Vergleich

Die Fetch API bietet für dieselbe Aufgabe oft mehrere Ansätze mit verschiedenen Trade-offs bei Lesbarkeit, Browserkompatibilität und Verhalten. Die Wahl des richtigen Patterns hängt vom Kontext ab – ein Service Worker hat andere Anforderungen als ein UI-Komponenten-Request.

Szenario Naiver Ansatz Empfohlenes Pattern Vorteil
HTTP-Fehler erkennen await fetch(url) direkt nutzen if (!response.ok) throw … 4xx/5xx werden nicht übersehen
Timeout setzen setTimeout + race() AbortSignal.timeout(ms) Native API, kein manuelles Cleanup
Große Antworten response.json() alles laden response.body ReadableStream Verarbeitung schon während Download
Token Refresh Jeder Request macht eigenen Refresh Singleton Refresh-Promise Kein Race Condition bei parallelen Requests
Response cachen response.json() dann cachen response.clone() vor dem Lesen Body kann nur einmal konsumiert werden

Ein häufiger Performance-Fehler: mehrere unabhängige Fetch-Requests sequenziell mit await ausführen. Statt const a = await fetchA(); const b = await fetchB(); sollte man const [a, b] = await Promise.all([fetchA(), fetchB()]) verwenden. Das halbiert die Wartezeit, wenn beide Requests unabhängig voneinander sind. Promise.allSettled() ist die richtige Wahl, wenn man alle Ergebnisse auch bei Teilfehlern auswerten möchte, ohne dass ein einzelner Fehler alle anderen abbricht.

Mironsoft

JavaScript-Entwicklung, API-Integration und Frontend-Architektur

Robuste API-Kommunikation für Ihre Webanwendung?

Wir implementieren Fetch-Schichten mit Retry-Logik, Token-Refresh, Streaming und Caching – wartbar, testbar und ohne externe HTTP-Bibliotheken. Von der Planung bis zur Produktion.

API-Architektur

Fetch-Wrapper mit Auth, Retry und Timeout für komplexe REST- und GraphQL-APIs

Performance-Audit

Identifikation sequenzieller Requests, fehlender Caching-Strategien und Streaming-Potenzial

Service Worker

Offline-first Caching-Strategien mit Fetch API und Cache API für PWAs

10. Zusammenfassung

Die Fetch API ist mächtig genug, um alle HTTP-Anforderungen moderner Webanwendungen ohne externe Bibliotheken zu erfüllen – vorausgesetzt, man kennt ihre Eigenheiten. Das Fehlermodell erfordert explizite response.ok-Prüfung. Timeouts benötigen AbortController oder AbortSignal.timeout(). Retry-Logik mit exponential Backoff schützt gegen transiente Fehler. Token-Refresh braucht ein Singleton-Promise gegen Race Conditions. ReadableStreams ermöglichen die Verarbeitung großer Datenmengen während des Downloads. Die Cache API ergänzt die Fetch API zu einem vollständigen Offline-first-Toolkit.

Der wichtigste Schritt zur Fetch API-Beherrschung ist die Kapselung in einen durchdachten Wrapper statt roher fetch()-Aufrufe überall im Code. Ein zentraler HTTP-Client, der Fehlerbehandlung, Auth, Logging und Retry vereint, macht alle einzelnen Requests einfacher und den gesamten Datenschicht-Code konsistenter. Mit AbortSignal.timeout(), nativen Streams und der Cache API bietet die Plattform heute alle Werkzeuge für produktionstaugliche HTTP-Kommunikation.

Fetch API Mastery — Das Wichtigste auf einen Blick

Fehlerbehandlung

Fetch löst bei HTTP-Fehlern keine Exception. Immer if (!response.ok) prüfen und Statuscode in benutzerdefinierter Fehlerklasse kapseln.

Timeouts

AbortSignal.timeout(ms) ist die sauberste Lösung ab Chrome 124 / Node 17.3. Ältere Umgebungen brauchen manuellen AbortController mit setTimeout.

Retry & Parallelität

Exponential Backoff mit Jitter für transiente Fehler. Promise.all() statt sequenziellem await für unabhängige Requests.

Streaming & Cache

response.body als ReadableStream für große Daten. response.clone() vor dem Cachen nicht vergessen.

11. FAQ: JavaScript Fetch API

1Warum wirft fetch() bei 404 keine Exception?
Die Fetch API betrachtet HTTP-Antworten als erfolgreiche Kommunikation. Nur echte Netzwerkfehler (kein Server erreichbar) führen zu abgelehnten Promises. response.ok immer prüfen.
2Wie setze ich ein Timeout?
AbortSignal.timeout(ms) ab Chrome 124 / Node 17.3. Für ältere Umgebungen: manueller AbortController mit setTimeout und clearTimeout nach erfolgreichem Request.
3no-cache vs. no-store?
no-cache validiert den Cache mit konditionellem Request. no-store umgeht den Cache vollständig – für sensible Daten die richtige Wahl.
4Warum response.clone() vor dem Cachen?
Ein Response-Body kann nur einmal konsumiert werden. response.clone() erstellt eine unabhängige Kopie mit eigenem Body-Stream.
5Race Conditions beim Token-Refresh?
Singleton-Refresh-Promise: erstes 401 startet Refresh und speichert das Promise. Alle anderen 401-Requests warten auf dasselbe Promise statt eigene Refreshes zu starten.
6Fetch API statt EventSource für SSE?
Ja – Fetch erlaubt Bearer Tokens und beliebige HTTP-Methoden. EventSource unterstützt keine Custom-Header. SSE-Format manuell über ReadableStream parsen.
7Wann credentials: 'include' verwenden?
Nur für Cross-Origin-Requests mit Cookies, wenn der Server Access-Control-Allow-Credentials: true setzt. Für Same-Origin-APIs reicht der Standard same-origin.
8Parallele Requests ausführen?
Promise.all() für unabhängige Requests. Promise.allSettled() wenn Teilfehler erlaubt sind. Sequenzielles await bei unabhängigen Requests ist ein häufiger Performance-Fehler.
9Welche Methoden dürfen automatisch wiederholt werden?
GET, HEAD, PUT, DELETE, OPTIONS sind idempotent und können sicher wiederholt werden. POST nur mit serverseitigem Idempotency-Key.
10ReadableStream statt response.json()?
ReadableStream verarbeitet Daten während des Downloads – speicherschonend bei großen Exporten, Log-Streams und KI-Antworten. Der Benutzer sieht erste Ergebnisse sofort.