Fehler in asynchronem Code verhalten sich anders als in synchronem Code
In synchronem JavaScript wandert ein geworfener Fehler den Call Stack nach oben, bis ihn irgendein try/catch auffängt – oder das Programm stürzt ab. Asynchroner Code durchbricht dieses Modell. Wenn ein Netzwerk-Request fehlschlägt, ist die Funktion, die ihn angestoßen hat, längst zurückgekehrt. Es gibt schlicht keinen Call Stack mehr, über den der Fehler nach oben wandern könnte.
Promises lösen das, indem sie Fehlern einen eigenen Kanal geben. Ein Promise kann entweder mit einem Wert erfüllt werden (fulfill) oder mit einem Grund abgelehnt werden (reject). Ein Reject ist das asynchrone Pendant zum throw. In diesem Kapitel geht es genau darum: sicherzustellen, dass Rejections dort landen, wo du sie kontrollieren kannst – statt einfach spurlos zu verschwinden.
Der try/catch läuft durch und wird sauber verlassen. Die Rejection passiert erst 50 ms später – da ist der try-Block längst Geschichte. Nichts fängt sie ab. Genau das ist die Falle.
try/catch funktioniert wieder – sobald await ins Spiel kommt
Sobald du ein Promise mit await abwartest, wird aus einer Rejection ein geworfener Fehler innerhalb der async-Funktion. Ein umschließender try/catch fängt ihn dann genauso ab wie einen ganz normalen synchronen throw:
Das ist das Muster, zu dem du als Erstes greifen solltest. Mit await holst du die asynchrone Welt zurück in die gewohnte try/catch-Form. Alle await-Aufrufe, die schiefgehen können, kommen in den try-Block – die Behandlung erledigst du im catch.
Ein Detail ist dabei wichtig: Abgedeckt wird nur der Aufruf, auf den tatsächlich await wartet. Wenn du ein Promise anstößt, ohne darauf zu warten, bleiben Fehler außen vor.
Der häufigste Bug: await vergessen
Rufst du eine async-Funktion ohne await auf (und gibst ihr Promise auch nicht zurück), rauschen Rejections einfach am umliegenden try/catch vorbei:
Der try-Block läuft sauber durch. Das Reject passiert erst im nächsten Tick – und da ist niemand mehr, der es abfangen könnte. In der Konsole landet dann eine Warnung wegen "unhandled promise rejection".
Die Lösung ist immer dieselbe: entweder die Funktion mit await aufrufen oder das Promise per return zurückgeben, damit der Aufrufer es awaiten kann.
.catch() ist die andere Seite derselben Medaille
Rejections lassen sich auch ganz ohne async/await behandeln – einfach per .catch() ankoppeln:
.catch(fn) ist die Kurzform von .then(undefined, fn). Damit fängst du jede Rejection ab, die weiter oben in der Kette passiert ist. Ein .catch() am Ende der Kette ist quasi das asynchrone Pendant zu einem äußeren try/catch — die letzte Verteidigungslinie, bevor aus dem Fehler eine „unhandled promise rejection" wird.
Beide Stile lassen sich problemlos kombinieren. Ein beliebtes Muster: Innerhalb der Funktion arbeitest du mit async/await, und der Aufrufer hängt bei Bedarf ein .catch() dran:
fetch wirft keinen Fehler bei HTTP-Statuscodes
Diese Falle erwischt wirklich jeden mindestens einmal. fetch lehnt das Promise nur bei echten Netzwerkfehlern ab – etwa wenn die DNS-Auflösung scheitert, die Verbindung abgelehnt wird oder der Request abgebrochen wurde. Eine Antwort mit Status 404 oder 500 gilt dagegen als erfolgreicher Fetch. Das Promise wird ganz normal aufgelöst – nur eben mit einer Response, deren ok auf false steht.
Wenn du möchtest, dass auch HTTP-Fehler in deinem catch-Block landen, musst du res.ok prüfen und den Fehler selbst werfen:
Spätestens wenn du diesen Boilerplate-Code zum zweiten Mal schreibst, lohnt es sich, ihn in einen Helper auszulagern.
Promise.all: Fail-Fast – im Gegensatz zu Promise.allSettled
Promise.all nimmt ein Array von Promises entgegen und liefert am Ende ein Array mit allen Ergebnissen zurück — es sei denn, eines der Promises wird rejected. Dann bricht Promise.all sofort mit genau diesem Fehler ab. Die übrigen Promises laufen zwar im Hintergrund weiter, aber ihre Ergebnisse landen im Nirwana.
Fail-fast ist genau das richtige Verhalten, wenn du alle Ergebnisse brauchst und ein einziger Fehler die gesamte Operation wertlos macht. Willst du dagegen jedes einzelne Ergebnis sehen – egal ob Erfolg oder Fehler ("probier diese fünf Uploads und sag mir, welche durchgelaufen sind und welche nicht") – dann nimm Promise.allSettled:
allSettled wirft niemals einen Fehler. Jeder Eintrag ist entweder {status: "fulfilled", value} oder {status: "rejected", reason}.
Fehler gezielt abfangen und weiterwerfen
Nicht jeder Fehler gehört in denselben Handler. Ein gängiges Muster ist: abfangen, prüfen und alles weiterwerfen, womit du nicht gerechnet hast:
Jeden Fehler mit einem nackten catch (err) {} zu schlucken, versteckt echte Bugs. Fange nur das ab, womit du sinnvoll umgehen kannst – den Rest wirfst du weiter.
Unhandled Rejections als letztes Sicherheitsnetz
Selbst bei sauberem Code rutscht irgendwann mal was durch. Sowohl Node.js als auch der Browser bieten dafür einen globalen Hook, der greift, wenn ein Promise-Fehler nirgends abgefangen wurde:
// Browser
window.addEventListener("unhandledrejection", event => {
console.error("unhandled:", event.reason);
event.preventDefault(); // unterdrückt die standardmäßige Konsolenwarnung
});
// Node.js
process.on("unhandledRejection", reason => {
console.error("unhandled:", reason);
});
Das ist kein Ersatz für sauberes Error Handling — eher ein letztes Sicherheitsnetz zum Loggen oder für Telemetrie. Im aktuellen Node.js führt eine unhandled Rejection standardmäßig zum Absturz des Prozesses, und genau das willst du in Produktion in der Regel auch haben. Fehler protokollieren, Prozess sterben lassen, sauber neu starten.
Checkliste für die Praxis
Wenn eine async-Funktion irgendetwas anfasst, das schiefgehen kann, frag dich:
- Steht jedes riskante
awaitin einemtry/catch-Block, oder fängt der Aufrufer das zurückgegebene Promise mit.catch()ab? - Wird der Aufruf auch wirklich
awaited, oder habe ich ihn versehentlich als Fire-and-Forget abgeschickt? - Speziell bei
fetch: prüfe ichres.ok, bevor ich der Response vertraue? - Bei parallelen Aufrufen — ist
Promise.allhier das richtige Werkzeug, oder brauche ich eherPromise.allSettled? - Gibt es ein übergeordnetes
.catch()oder einenunhandledrejection-Handler, damit kein Fehler stillschweigend verschwindet?
Wenn diese fünf Punkte sitzen, hört dein async-Code auf, dich mit Fehlern zu überraschen, die sich einfach in der Event Loop auflösen.
Weiter geht's: ES-Module
Damit ist das Kapitel zur Fehlerbehandlung in async-Code abgerundet. Als Nächstes schauen wir uns an, wie JavaScript-Code über mehrere Dateien hinweg organisiert wird — import, export und das Modulsystem, auf dem jedes moderne Projekt aufbaut.
Häufig gestellte Fragen
Wie fängt man Fehler in einer async-Funktion ab?
Die await-Aufrufe kommen in einen try/catch-Block. Lehnt ein awaiteter Promise ab, wird daraus ein geworfener Fehler, den catch abfängt. Alternativ lässt du den Fehler weiterlaufen und behandelst ihn an der Aufrufstelle per .catch() auf dem zurückgegebenen Promise.
Warum fängt mein try/catch den Fehler nicht ab?
Meistens, weil der Fehler in Code passiert, der gar nicht awaitet wird. Rufst du eine async-Funktion ohne await auf (und gibst ihren Promise auch nicht zurück), entkommt jede Rejection dem umgebenden try/catch. Also: immer await oder return auf dem Promise, dessen Fehler du mitbekommen willst.
Was passiert, wenn ein Promise ablehnt und niemand ihn catcht?
Dann hast du eine Unhandled Rejection. Node.js feuert das unhandledRejection-Event und beendet in neueren Versionen den Prozess standardmäßig mit einem Crash. Im Browser wird window.onunhandledrejection ausgelöst und eine Warnung geloggt. In beiden Fällen gilt: entweder .catch() dranhängen oder mit try/catch um ein await absichern.
Wie verhält sich Promise.all bei Fehlern?
Promise.all lehnt ab, sobald einer der übergebenen Promises ablehnt. Die übrigen Promises laufen zwar weiter, ihre Ergebnisse landen aber im Nirwana. Willst du jedes Resultat sehen, egal ob erfolgreich oder nicht, nimm Promise.allSettled – das liefert ein Array mit {status, value}- bzw. {status, reason}-Einträgen zurück.