Promise-Fehlerbehandlung in JavaScript: Typische Fallstricke
AI generated
JS
() =>
JavaScript · Promises · Debugging
Promise-Fehlerbehandlung in JavaScript
typische Fallstricke bei try catch und async await

Ein Fehler in einer asynchronen Funktion verschwindet spurlos, die Konsole zeigt eine unhandled rejection, ein leerer catch-Block versteckt genau das Problem, das gerade gesucht wird. Promise-Fehlerbehandlung folgt eigenen Regeln, die sich von synchronem try catch unterscheiden. Dieser Artikel zeigt die konkreten Fallstricke und wie man sie zuverlässig vermeidet.

18 Min. Lesezeit try catch · async await · Promise.all · allSettled Debugging · Fehlerbehandlung

1. Warum Fehlerbehandlung bei Promises anders funktioniert als bei synchronem Code

In synchronem Code genügt ein einziger try catch Block, um jeden geworfenen Fehler innerhalb seines Bereichs zuverlässig abzufangen, weil die Ausführung linear und blockierend abläuft. Promise-Fehlerbehandlung folgt einer anderen Logik, weil ein Promise seinen Erfolgs- oder Fehlerzustand erst zu einem späteren, nicht vorhersehbaren Zeitpunkt erreicht, oft nachdem der umgebende synchrone Code bereits vollständig durchgelaufen ist. Ein Fehler, der innerhalb eines Promise-Callbacks geworfen wird, verlässt niemals den Promise selbst, er wird stattdessen in dessen internem Zustand als Rejection gespeichert und muss aktiv abgeholt werden.

Genau diese Verschiebung von synchronem Werfen zu asynchronem Speichern ist die Wurzel fast aller Fallstricke rund um Promise-Fehlerbehandlung. Ein try catch Block, der eine asynchrone Operation umschließt, ohne korrekt mit await zu synchronisieren, sieht den späteren Fehler schlicht nicht, weil der Block zum Zeitpunkt des Fehlers bereits verlassen wurde. Wer diese zeitliche Verschiebung nicht verinnerlicht hat, verliert Fehler in scheinbar korrekt aussehendem Code, ohne dass eine Fehlermeldung im eigenen catch-Block jemals ankommt.

Die folgenden Abschnitte zeigen die konkreten Situationen, in denen Promise-Fehlerbehandlung typischerweise fehlschlägt: vergessenes await, unhandled rejections, leere catch-Blöcke und das unterschiedliche Fehlerverhalten von Promise.all gegenüber Promise.allSettled.

2. Vergessenes await: wenn try catch den Fehler nicht fängt

Der häufigste Einstiegspunkt in fehlerhafte Promise-Fehlerbehandlung ist ein aufgerufenes Promise ohne await innerhalb eines try catch Blocks. Ohne await gibt die asynchrone Funktion sofort ein noch ausstehendes Promise zurück, der try catch Block wird augenblicklich verlassen, lange bevor die eigentliche asynchrone Operation überhaupt abgeschlossen ist. Schlägt die Operation später fehl, existiert der try catch Block zu diesem Zeitpunkt nicht mehr, der Fehler landet stattdessen als unhandled rejection außerhalb jeder Fehlerbehandlung.

Dieser Fallstrick ist besonders tückisch, weil der Code beim Lesen völlig korrekt aussieht: try, ein asynchroner Funktionsaufruf, catch, alles scheint an seinem Platz. Nur das fehlende await-Schlüsselwort entscheidet darüber, ob die Promise-Fehlerbehandlung tatsächlich funktioniert oder ins Leere läuft. Genau deshalb ist eine ESLint-Regel wie require-await in Kombination mit no-floating-promises aus dem TypeScript-ESLint-Plugin so wertvoll, weil sie fehlendes await bereits im Editor markiert, statt es erst als stillen Produktionsfehler zu entdecken.


async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`User ${id} not found`);
  }
  return response.json();
}

// WRONG: missing "await" — the try/catch block exits before the error occurs
async function loadUserWrong(id) {
  try {
    fetchUser(id); // no await! The promise rejection is never caught here
  } catch (error) {
    console.log('This will never run for async errors from fetchUser');
  }
}

// RIGHT: awaiting the promise lets try/catch see the rejection
async function loadUserRight(id) {
  try {
    const user = await fetchUser(id);
    return user;
  } catch (error) {
    console.error('Failed to load user:', error.message);
    throw error; // re-throw if the caller also needs to react
  }
}

3. Unhandled Rejections: stille Fehler, die niemand sieht

Ein Promise, das ohne jede angehängte Fehlerbehandlung abgelehnt wird, erzeugt eine sogenannte unhandled rejection. Im Browser erscheint dazu eine Warnung in der Konsole, in Node.js kann eine unhandled rejection je nach Version und Konfiguration sogar den gesamten Prozess beenden. Der eigentliche Fallstrick liegt darin, dass eine unhandled rejection nicht sofort beim Werfen des Fehlers entsteht, sondern erst, nachdem die Microtask-Queue vollständig abgearbeitet wurde und feststeht, dass kein catch mehr nachträglich angehängt wird.

In der Praxis entstehen unhandled rejections häufig bei Fire-and-Forget-Aufrufen, bei denen ein Promise absichtlich nicht abgewartet wird, etwa beim Versenden von Analytics-Events, die den Hauptablauf nicht blockieren sollen. Wird für solche Aufrufe keine explizite Fehlerbehandlung ergänzt, verschwindet ein fehlgeschlagener Analytics-Aufruf zwar unauffällig aus Nutzersicht, erzeugt aber in der Konsole wiederkehrendes Rauschen, das echte Fehler überdeckt. Die robuste Lösung ist, jedes bewusst nicht abgewartete Promise trotzdem mit einem eigenen .catch() zu versehen, selbst wenn dieser nur protokolliert und keine weitere Aktion auslöst.


// WRONG: fire-and-forget without any error handling produces an unhandled rejection
function trackEvent(name) {
  fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ name }) });
  // If this fetch fails, the rejection is never handled anywhere
}

// RIGHT: always attach a catch, even for intentionally unawaited promises
function trackEventSafe(name) {
  fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ name }) })
    .catch((error) => console.warn('Analytics call failed, ignoring:', error.message));
}

// Node.js: a global safety net, but it should never replace local handling
process.on('unhandledRejection', (reason) => {
  console.error('Unhandled rejection detected:', reason);
});

4. Fehler in .then-Ketten: wo catch tatsächlich greift

In einer Kette aus mehreren .then()-Aufrufen fängt ein am Ende angehängter .catch() Fehler ab, die in jedem vorangegangenen .then()-Callback auftreten, nicht nur im unmittelbar vorherigen. Das liegt daran, dass eine Rejection die Kette überspringt, bis sie auf den nächsten Handler trifft, der tatsächlich einen Fehlerfall behandelt, egal wie viele erfolgreiche .then()-Schritte dazwischenliegen. Diese Eigenschaft macht einen einzigen, abschließenden .catch() zu einem bequemen, aber manchmal zu groben Werkzeug für Promise-Fehlerbehandlung, weil er nicht unterscheidet, in welchem Kettenglied der Fehler tatsächlich entstanden ist.

Wer unterschiedliche Fehlerarten aus verschiedenen Kettengliedern unterschiedlich behandeln möchte, etwa einen Netzwerkfehler anders als einen Validierungsfehler, sollte lokale .catch()-Handler zwischen einzelnen Kettengliedern platzieren, die den Fehler entweder final behandeln oder gezielt weiterreichen. Bei async/await entspricht diesem Muster ein try catch Block pro logischem Abschnitt statt eines einzigen Blocks um die gesamte Funktion, was in der Praxis oft übersichtlicher ist als tief verschachtelte .then()-Ketten mit mehreren .catch()-Aufrufen.


// A single trailing catch handles errors from ANY step in the chain
fetch('/api/data')
  .then((response) => response.json())
  .then((data) => processData(data))
  .then((result) => saveResult(result))
  .catch((error) => console.error('Failed at some step:', error.message));

// Equivalent with async/await, easier to reason about which step failed
async function loadAndProcess() {
  let data;
  try {
    const response = await fetch('/api/data');
    data = await response.json();
  } catch (error) {
    throw new Error(`Network or parsing failed: ${error.message}`);
  }

  try {
    const result = processData(data);
    return await saveResult(result);
  } catch (error) {
    throw new Error(`Processing or saving failed: ${error.message}`);
  }
}

5. Promise.all Fail-Fast vs. Promise.allSettled im Vergleich

Promise.all() folgt einem Fail-Fast-Verhalten: Sobald ein einziges der übergebenen Promises abgelehnt wird, lehnt auch das zusammengesetzte Promise sofort ab, unabhängig davon, ob die übrigen Promises noch erfolgreich abgeschlossen wären. Das ist genau richtig, wenn alle Teilanfragen zwingend erfolgreich sein müssen, etwa beim parallelen Laden mehrerer Pflichtressourcen für eine Seite, wird aber zum Fallstrick, wenn einzelne fehlgeschlagene Anfragen toleriert werden sollen und die Ergebnisse der übrigen, erfolgreichen Anfragen trotzdem benötigt werden.

Promise.allSettled() löst genau dieses Problem, indem es niemals selbst ablehnt, sondern für jedes übergebene Promise ein Ergebnisobjekt mit dem Status fulfilled oder rejected zurückgibt. Die aufrufende Stelle entscheidet dann selbst, wie mit einzelnen Fehlschlägen umzugehen ist, statt dass ein einzelner Fehlschlag automatisch alle anderen Ergebnisse verwirft. Wer beide Methoden verwechselt, etwa Promise.all() für tolerante Parallelverarbeitung nutzt, verliert bei jedem einzelnen Fehlschlag sämtliche bereits erfolgreich geladenen Daten der übrigen Promises.


const requests = [
  fetch('/api/users'),
  fetch('/api/orders'),
  fetch('/api/broken-endpoint') // simulate one failing request
];

// Promise.all: fail-fast, one rejection discards all other results
try {
  const results = await Promise.all(requests);
  console.log('All succeeded:', results.length);
} catch (error) {
  console.log('One failure discarded everything:', error.message);
}

// Promise.allSettled: every result is preserved, regardless of individual failures
const settled = await Promise.allSettled(requests);
const successful = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const failed = settled.filter((r) => r.status === 'rejected').map((r) => r.reason);
console.log(`${successful.length} succeeded, ${failed.length} failed`);

6. Fehler schlucken durch leere catch-Blöcke

Ein leerer oder fast leerer catch-Block ist eine der schädlichsten Formen von Promise-Fehlerbehandlung, weil er Fehler zwar formal abfängt, aber ihre Information vollständig vernichtet. Ein catch (error) {} ohne jede Aktion sorgt dafür, dass eine fehlgeschlagene Operation nach außen wie ein Erfolg aussieht, während das eigentliche Problem irgendwo im System weiterschwelt, oft mit inkonsistentem Zustand als Folge. Genau diese Art der stillen Fehlerbehandlung ist im Debugging besonders zeitaufwendig, weil der ursprüngliche Fehler zum Zeitpunkt der Untersuchung längst gelöscht ist und keine Spur mehr im Log hinterlassen hat.

Selbst wenn ein Fehler bewusst ignoriert werden soll, weil er im gegebenen Kontext unkritisch ist, sollte der catch-Block diese Entscheidung explizit dokumentieren und den Fehler zumindest protokollieren, statt ihn kommentarlos verschwinden zu lassen. Ein knapper Kommentar im Code, der erklärt, warum ein bestimmter Fehlerfall absichtlich keine weitere Aktion auslöst, unterscheidet eine bewusste Designentscheidung von einem vergessenen, unfertigen catch-Block, den ein späterer Entwickler beim Code-Review sofort hinterfragen würde.


// WRONG: silently swallows the error, no trace left for debugging
async function saveDraftSilent(draft) {
  try {
    await api.save(draft);
  } catch (error) {
    // nothing here — the failure is now invisible
  }
}

// RIGHT: log at minimum, even for errors considered non-critical
async function saveDraftSafe(draft) {
  try {
    await api.save(draft);
  } catch (error) {
    // Draft auto-save failures are non-critical, but must remain visible
    console.warn('Draft auto-save failed, will retry on next change:', error.message);
  }
}

7. async/await in Schleifen: Fehlerbehandlung pro Iteration

Ein try catch Block, der eine gesamte Schleife mit mehreren await-Aufrufen umschließt, bricht die komplette Schleife beim ersten Fehler ab, weil der geworfene Fehler die Kontrolle sofort an den umschließenden catch-Block übergibt und alle weiteren Iterationen niemals ausgeführt werden. Das ist korrekt, wenn ein einziger Fehlschlag den gesamten Vorgang ungültig machen soll, wird aber zum Fallstrick, wenn einzelne Iterationen unabhängig voneinander sind und ein Fehlschlag in Iteration drei nicht verhindern soll, dass die Iterationen vier und fünf trotzdem verarbeitet werden.

Für unabhängige Iterationen gehört die Promise-Fehlerbehandlung deshalb in den Schleifenkörper selbst, mit einem eigenen try catch Block pro Durchlauf, der Fehler lokal protokolliert und die Schleife anschließend fortsetzt. Diese Unterscheidung zwischen alles-oder-nichts-Verarbeitung und unabhängiger Verarbeitung pro Element ist eine bewusste Designentscheidung, die vor dem Schreiben der Schleife getroffen werden sollte, statt sich zufällig aus der Position des try catch Blocks zu ergeben.


const userIds = [1, 2, 3, 4, 5];

// WRONG (if independent processing is desired): one failure stops everything
async function processAllOrNothing(ids) {
  const results = [];
  try {
    for (const id of ids) {
      results.push(await fetchUser(id)); // any rejection aborts the whole loop
    }
  } catch (error) {
    console.error('Processing stopped at first failure:', error.message);
  }
  return results;
}

// RIGHT for independent items: catch inside the loop body, per iteration
async function processIndependently(ids) {
  const results = [];
  for (const id of ids) {
    try {
      results.push(await fetchUser(id));
    } catch (error) {
      console.warn(`Skipping user ${id}, fetch failed:`, error.message);
    }
  }
  return results; // contains every successful result, failures are just skipped
}

8. Globale Handler: das unhandledrejection-Event richtig nutzen

Sowohl der Browser als auch Node.js bieten ein globales Event für unhandled rejections, im Browser über window.addEventListener('unhandledrejection', ...), in Node.js über process.on('unhandledRejection', ...). Dieser globale Handler ist ein wertvolles letztes Sicherheitsnetz für Monitoring und Fehlerprotokollierung in Produktion, sollte aber niemals als Ersatz für lokale Promise-Fehlerbehandlung an der eigentlichen Fehlerquelle dienen, weil er den Kontext verliert, in dem der Fehler ursprünglich entstanden ist.

Ein sinnvoller Einsatz des globalen Handlers ist die Anbindung an ein Fehler-Tracking-System wie Sentry, damit jede unhandled rejection, die trotz sorgfältiger lokaler Fehlerbehandlung durch das System rutscht, zumindest zentral erfasst und ausgewertet wird. Der globale Handler ersetzt aber keine gute lokale Promise-Fehlerbehandlung, er ergänzt sie lediglich als Rückfallebene für die Fälle, die im Code-Review oder Testing übersehen wurden.


// Browser: global safety net, wired into an error tracking service
window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);
  // errorTrackingService.captureException(event.reason);
  event.preventDefault(); // optional: suppress the default browser console warning
});

// Node.js equivalent, useful in servers and background workers
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  // errorTrackingService.captureException(reason);
});

9. Fehlerbehandlungsmuster im direkten Vergleich

Je nach Situation unterscheidet sich, welches Muster für Promise-Fehlerbehandlung am besten passt, und die Wahl hängt stark davon ab, ob ein Fehlschlag den gesamten Vorgang oder nur einen Teil davon betreffen soll.

Situation Häufiger Fehler Empfohlenes Muster Warum es funktioniert
Async-Aufruf in try catch await vergessen Immer await vor der asynchronen Funktion catch sieht den Fehler nur bei Synchronisierung
Fire-and-Forget-Aufruf Kein .catch angehängt .catch() auch bei bewusst nicht abgewarteten Promises Verhindert unhandled rejections
Mehrere unabhängige Anfragen Promise.all verwirft alle Ergebnisse Promise.allSettled Erfolgreiche Ergebnisse bleiben erhalten
Unkritischer Fehler Leerer catch-Block Mindestens protokollieren mit Kommentar Bewusste Entscheidung bleibt nachvollziehbar
Schleife mit unabhängigen Items Ein Fehler bricht alles ab try catch im Schleifenkörper Andere Iterationen laufen trotzdem weiter

Die Tabelle zeigt, dass gute Promise-Fehlerbehandlung keine pauschale Regel ist, sondern eine bewusste Entscheidung pro Situation erfordert. Wer vor dem Schreiben des Codes klärt, ob ein Fehlschlag den gesamten Vorgang oder nur einen Teil betreffen soll, vermeidet die meisten der hier gezeigten Fallstricke von Anfang an.

Mironsoft

JavaScript-Debugging, Code-Reviews und Frontend-Architektur

Verschwinden Fehler in eurem asynchronen Code?

Wir prüfen bestehenden Code auf riskante Promise-Fehlerbehandlung, schließen fehlende await-Aufrufe und leere catch-Blöcke und richten Monitoring für unhandled rejections ein.

Code-Review

Gezielte Suche nach fehlendem await und leeren catch-Blöcken

Refactoring

Promise.allSettled statt Promise.all, wo es passt

Monitoring-Setup

unhandledrejection-Handler an Fehler-Tracking anbinden

10. Zusammenfassung

Promise-Fehlerbehandlung folgt eigenen Regeln, die sich von synchronem try catch grundlegend unterscheiden, weil ein Fehler in einem Promise erst zu einem späteren Zeitpunkt sichtbar wird und aktiv abgeholt werden muss. Der häufigste Fallstrick ist ein vergessenes await, das einen try catch Block wirkungslos macht, gefolgt von unhandled rejections bei Fire-and-Forget-Aufrufen ohne .catch() und leeren catch-Blöcken, die Fehlerinformation stillschweigend vernichten.

Die Wahl zwischen Promise.all() und Promise.allSettled() entscheidet darüber, ob ein einzelner Fehlschlag alle anderen Ergebnisse mit sich reißt oder ob erfolgreiche Teilergebnisse erhalten bleiben. Bei Schleifen mit unabhängigen Iterationen gehört die Fehlerbehandlung in den Schleifenkörper selbst, nicht um die gesamte Schleife herum. Ein globaler unhandledrejection-Handler ergänzt gute lokale Promise-Fehlerbehandlung als letztes Sicherheitsnetz, ersetzt sie aber niemals.

Promise-Fehlerbehandlung in JavaScript, das Wichtigste auf einen Blick

Grundregel

try catch sieht einen Promise-Fehler nur, wenn korrekt mit await synchronisiert wird.

Fire-and-Forget

Auch bewusst nicht abgewartete Promises brauchen ein eigenes .catch(), sonst entsteht eine unhandled rejection.

Parallele Anfragen

Promise.allSettled statt Promise.all, wenn einzelne Fehlschläge toleriert werden sollen.

Debugging

Nie leere catch-Blöcke, immer mindestens protokollieren, globalen unhandledrejection-Handler als Rückfallebene nutzen.

11. FAQ: Promise-Fehlerbehandlung in JavaScript

1Warum fängt mein try catch den Fehler nicht?
Vermutlich fehlt await vor dem Funktionsaufruf, der Block wird sonst vorzeitig verlassen.
2Was ist eine unhandled rejection?
Ein abgelehntes Promise ohne angehängten catch-Handler, sichtbar als Konsolenwarnung oder Prozessende in Node.js.
3Brauche ich catch bei Fire-and-Forget?
Ja, auch nicht abgewartete Promises können fehlschlagen und erzeugen sonst eine unhandled rejection.
4Wo greift catch in einer .then-Kette?
Er fängt Fehler aus jedem vorangegangenen Schritt ab, nicht nur aus dem unmittelbar vorherigen.
5Unterschied Promise.all vs. allSettled?
all lehnt bei einem Fehlschlag sofort ab, allSettled liefert immer alle Ergebnisse mit Status.
6Warum ist ein leerer catch-Block problematisch?
Er vernichtet die Fehlerinformation vollständig, ohne Spur im Log für spätere Diagnose.
7Fehler in Schleife mit await richtig behandeln?
try catch in den Schleifenkörper legen, damit nur die betroffene Iteration übersprungen wird.
8Was macht das unhandledrejection-Event?
Ein globales Sicherheitsnetz für Monitoring, ersetzt aber keine gute lokale Fehlerbehandlung.
9Fängt try catch synchrone und asynchrone Fehler gleichzeitig?
Ja, solange jede asynchrone Operation im Block mit await synchronisiert wird.
10Welche ESLint-Regel hilft gegen vergessenes await?
no-floating-promises aus dem TypeScript-ESLint-Plugin markiert nicht abgewartete Promises im Editor.