Warten ohne Blockieren

Was await anhält und was währenddessen weiterläuft

await pausiert deine Funktion und nicht den Browser: Alles nach dem await wird als Microtask eingereiht, und dieselbe Regel erklärt die meisten Fehler in asynchronem Code.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Asynchroner Code liest sich wie synchroner und läuft trotzdem anders

Der Reiz von async und await ist genau das: Die Zeile const daten = await laden(id) sieht aus wie eine gewöhnliche Zuweisung. Deshalb wird sie auch wie eine gewöhnliche Zuweisung gelesen, und die Frage, was zwischen dem Aufruf und dem Ergebnis passiert, stellt niemand mehr. In dieser Lücke sitzen die Fehler, die später am schwersten zu finden sind.

Am teuersten ist das await in der Schleife. Zehn unabhängige Detailabfragen, jede mit rund 200 Millisekunden Antwortzeit, nacheinander abgewartet: Das sind zwei Sekunden, bis die Liste vollständig ist, obwohl die zehn Anfragen einander nichts angehen. Blockiert ist der Browser in dieser Zeit nicht, er hat nur nichts anzuzeigen, und für die Bedienung ist das derselbe Eindruck. Derselbe Code mit Promise.all ist nach der langsamsten Antwort fertig, also nach rund 200 Millisekunden. Die Umstellung kostet zwei Zeilen und ändert an der Logik nichts.

Das zweite Muster ist der Fehler, der nirgends ankommt: eine async-Funktion, die niemand abwartet und an die niemand ein catch hängt. Im Browser landet die Ablehnung als Uncaught (in promise) in der Konsole und sonst nirgends, kein Fehlerdialog, kein Eintrag in der Fehlerüberwachung. In Node beendet eine unbehandelte Ablehnung den Prozess seit Version 15 mit einem Fehlercode, und zwar an einer Stelle, die mit der Ursache nichts zu tun hat.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Aufbau im Detail

Die Anatomie einer async-Funktion

Vier Zeichenfolgen entscheiden über das Verhalten der ganzen Funktion. Wer weiß, was jede davon auslöst, sieht einer fremden Stelle im Code an, wann sie zurückkehrt und wo ein Fehler landet.

Der Aufbau

async function preisLaden(id) { const antwort = await fetch(pfad(id)); return antwort.json(); }
  1. 01 Der Rückgabewert steht damit fest async

    Eine als async markierte Funktion gibt immer ein Promise zurück, auch wenn im Rumpf kein einziges await steht. Ein return 5 wird zu einem erfüllten Promise, ein throw zu einem abgelehnten. Selbst ein Fehler, der noch vor dem ersten await auftritt, kommt nicht als synchrone Ausnahme beim Aufrufer an, sondern als Ablehnung.

  2. 02 Der Wartepunkt, der nur diese Funktion betrifft await

    Hier endet der synchrone Teil. Die Funktion gibt den Ausführungsstrang zurück und meldet sich erst wieder, wenn das erwartete Promise entschieden ist. Auch ein await auf einen einfachen Wert ohne Promise erzeugt diese Unterbrechung, der Rest der Funktion läuft dann eben im nächsten Microtask.

  3. 03 Der Aufruf, der sofort losläuft fetch(pfad(id))

    Die Anfrage startet in dem Moment, in dem diese Zeile ausgeführt wird, nicht durch das await davor. Genau darauf beruht der Trick mit Promise.all: Erst alle Aufrufe erzeugen, dann warten. Umgekehrt hilft das await auch nicht dabei, eine Anfrage später zu starten.

  4. 04 Der zweite Wartepunkt, den viele übersehen antwort.json()

    Das Auslesen des Antwortkörpers ist selbst asynchron und liefert wieder ein Promise. Ohne await oder ohne return steht in der Variablen später ein Promise statt der Daten, und der Fehler fällt an einer ganz anderen Stelle auf, meist als undefined in einer Eigenschaft.

  5. 05 Das Ergebnis wird eingepackt return

    Der zurückgegebene Wert landet nicht direkt beim Aufrufer, sondern erfüllt das Promise der Funktion. Gibst du innerhalb eines try ein Promise ohne await zurück, ist die Funktion vor der Ablehnung fertig, und dein catch sieht den Fehler nie.

Wenn es nicht funktioniert

Das siehst du

In der Konsole steht Promise { <pending> } statt der erwarteten Daten.

Warum

Der Aufrufer hat das Promise nicht abgewartet, sondern die async-Funktion wie eine synchrone benutzt.

Was hilft

Ein await davorsetzen und den Aufrufer selbst als async markieren, oder das Ergebnis mit then weiterverarbeiten. Der Rückgabewert einer async-Funktion ist nie der Wert selbst.

Das siehst du

Eine Schleife mit forEach ist längst durchgelaufen, bevor ein einziges Ergebnis vorliegt.

Warum

forEach wirft den Rückgabewert des Rückrufs weg, das Promise aus der async-Funktion wird also niemals abgewartet.

Was hilft

for...of mit await verwenden, wenn die Reihenfolge zählt, sonst map plus Promise.all. map gibt die Promises zurück, forEach nicht.

Das siehst du

Ein Fehler aus einer aufgerufenen Funktion kommt im umgebenden try/catch nie an.

Warum

Im try steht return laden() statt return await laden(). Die Funktion ist damit fertig, bevor die Ablehnung überhaupt entsteht.

Was hilft

return await schreiben, oder den Fehler dort behandeln, wo das Promise tatsächlich abgewartet wird. Außerhalb des try greift der Block nicht mehr.

Das siehst du

Node beendet den Prozess mit einer unbehandelten Ablehnung, obwohl an allen sichtbaren Stellen try/catch steht.

Warum

Irgendwo wird eine async-Funktion aufgerufen, ohne das Ergebnis abzuwarten oder ein catch anzuhängen, oft in einem Ereignisrückruf oder in einem Aufräumpfad.

Was hilft

Den Aufruf abwarten oder bewusst mit catch abschließen. Ein Zuhörer auf unhandledRejection zeigt beim Suchen, welches Promise betroffen ist.

Das siehst du

Die Oberfläche friert ein, obwohl die Funktion durchgehend async ist.

Warum

Zwischen den Wartepunkten läuft Rechenarbeit, und await gibt den Ausführungsstrang nur an den Wartepunkten frei.

Was hilft

Die Berechnung in Abschnitte teilen und dazwischen an das Ereignisloop zurückgeben oder sie in einen Web Worker verlagern. async allein erzeugt keine Nebenläufigkeit.

Ein await, sechs Stationen

  1. 01 Der Aufruf läuft synchron durch, bis er das erste await erreicht.
  2. 02 Das erwartete Promise hat seine Arbeit schon beim Erzeugen begonnen.
  3. 03 Die Funktion gibt den Strang frei und liefert ein noch offenes Promise zurück.
  4. 04 Der aufrufende Code läuft weiter, bis der aktuelle Task zu Ende ist.
  5. 05 Ist das Promise erfüllt, kommt der Rest der Funktion als Microtask zurück.
  6. 06 Erst wenn keine Microtasks mehr offen sind, zeichnet der Browser wieder.
Was du mitnimmst

Was du danach ohne Ausprobieren vorhersagen kannst

Drei Fragen reichen für fast jede Stelle mit await: Wann beginnt die Arbeit, wer wartet worauf, und wo landet ein Fehler. Wer die beantworten kann, braucht keine Konsolenausgaben mehr, um die Reihenfolge im eigenen Programm zu verstehen.

Den Startzeitpunkt vom Wartepunkt trennen

Ein Promise beginnt zu arbeiten, sobald es erzeugt wird, nicht erst beim await. Wer zuerst alle Aufrufe startet und danach mit Promise.all wartet, verkürzt die Gesamtdauer auf die langsamste Antwort, ohne eine einzige Zeile Logik zu ändern.

Sequenziell nur, wo es sein muss

Ein await in der Schleife ist richtig, wenn der nächste Aufruf das Ergebnis des vorherigen braucht oder wenn die Gegenstelle ein Anfragelimit setzt. In allen anderen Fällen ist es reine Wartezeit, die sich mit jedem Durchlauf addiert.

Die passende Sammelfunktion wählen

Promise.all bricht beim ersten Fehler ab, Promise.allSettled liefert für jeden Eintrag Ergebnis oder Grund, Promise.any nimmt den ersten Erfolg. Diese Wahl entscheidet, ob ein einzelner ausgefallener Dienst die ganze Seite leer lässt.

Fehler dort fangen, wo du reagieren kannst

try/catch um ein await funktioniert wie gewohnt. Nur bei return laden() innerhalb des try greift es nicht, weil die Funktion vor der Ablehnung fertig ist. return await laden() schließt diese Lücke.

Kein Promise ohne Abnehmer

Jeder Aufruf einer async-Funktion gibt ein Promise zurück. Wird es weder abgewartet noch mit catch versehen noch weitergereicht, verschwindet der Fehlerfall aus dem Programm. Die Regel no-floating-promises aus typescript-eslint findet solche Stellen automatisch.

Rechenarbeit gehört nicht in async

await gibt den Ausführungsstrang ausschließlich an den Wartepunkten frei. Eine Schleife über hunderttausend Einträge blockiert in einer async-Funktion genauso wie in jeder anderen, dafür braucht es Aufteilen oder einen Web Worker.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Was await tatsächlich anhält

Eine async-Funktion läuft beim Aufruf zunächst ganz normal weiter, und zwar synchron, bis zur ersten Zeile mit await. Erst dort kehrt sie zum Aufrufer zurück und übergibt ihm ein noch offenes Promise. Alles hinter dem await ist eine Fortsetzung, die eingeplant wird, sobald das erwartete Promise erfüllt oder abgelehnt ist. Der Ausführungsstrang selbst steht in dieser Zeit nicht still, er bearbeitet weiter, was in der Warteschlange liegt.

Das erklärt eine Reihenfolge, die auf den ersten Blick verkehrt aussieht. Steht in der Funktion eine Ausgabe vor dem await und eine danach, und gibt der Aufrufer direkt nach dem Aufruf ebenfalls etwas aus, dann erscheint die Ausgabe des Aufrufers in der Mitte. Zufall ist daran nichts: Der Teil hinter dem await kann frühestens laufen, wenn der aktuelle Ausführungsblock zu Ende ist.

Auch ein await auf etwas, das gar kein Promise ist, erzeugt diese Unterbrechung. await 5 hält die Funktion an und setzt sie im nächsten Microtask fort. Wer eine rein rechnende Funktion vorsichtshalber als async markiert, verschiebt ihr Ergebnis damit um mindestens einen Durchgang, ohne irgendetwas zu gewinnen.

Microtasks kommen vor allem anderen

Jede JavaScript-Umgebung führt zwei Sorten von Warteschlangen. Rückrufe aus setTimeout, aus Nachrichten und aus Benutzerereignissen landen als Task in der einen. Promise-Fortsetzungen, queueMicrotask und die Rückrufe von MutationObserver landen in der Microtask-Warteschlange. Nach jedem einzelnen Task arbeitet die Umgebung die Microtask-Warteschlange vollständig ab, einschließlich der Microtasks, die währenddessen neu hinzukommen.

Praktisch heißt das: Ein Promise.resolve().then(...) läuft vor einem setTimeout mit 0 Millisekunden, selbst wenn der Zeitgeber vorher eingerichtet wurde. Und eine Kette, die sich in jedem Microtask selbst neu einplant, kommt nie zum Ende, weil zwischen zwei Microtasks nicht gezeichnet wird. Der Tab wirkt dann eingefroren, obwohl keine einzige lange Funktion läuft.

setTimeout mit 0 ist übrigens kein Nullwert. Sobald mehr als fünf Zeitgeber ineinander verschachtelt sind, hebt der Browser eine Wartezeit unter vier Millisekunden laut HTML-Standard auf vier an, und Tabs im Hintergrund werden zusätzlich gedrosselt. Wenn du nur ans Ende der laufenden Arbeit willst, ist queueMicrotask das genauere Werkzeug. Wenn du dem Browser dazwischen das Zeichnen erlauben willst, brauchst du eine echte Aufgabengrenze.

Nacheinander oder gleichzeitig entscheidest du beim Erzeugen

Eine Schleife mit for (const id of ids) { const preis = await preisLaden(id); } wartet so oft hintereinander, wie die Liste Einträge hat. Das ist genau dann richtig, wenn der nächste Aufruf vom vorherigen Ergebnis abhängt. Sind die Aufrufe unabhängig, gehört das Erzeugen vom Warten getrennt: const promises = ids.map(id => preisLaden(id)); const preise = await Promise.all(promises); Eine Falle steckt dabei in der Kurzform ids.map(preisLaden), denn map übergibt dem Rückruf drei Argumente, nämlich Element, Index und das ganze Array. Hat deine Funktion einen zweiten optionalen Parameter, bekommt sie plötzlich den Index hineingereicht.

Promise.all lehnt ab, sobald das erste Promise ablehnt, aber die übrigen Aufrufe laufen weiter. Abgebrochen wird nichts, denn ein Promise kennt kein Abbrechen. Wer eine Anfrage wirklich stoppen will, braucht einen AbortController und muss dessen Signal an fetch übergeben. Umgekehrt sorgt Promise.all dafür, dass spätere Ablehnungen der anderen Promises als behandelt gelten, sie tauchen also nicht als unbehandelte Ablehnung auf.

Wenn ein einzelner Ausfall nicht alles kippen soll, ist Promise.allSettled die richtige Wahl, denn es wartet auf alle und liefert je Eintrag status und wert oder grund. Promise.any nimmt den ersten Erfolg und lehnt erst ab, wenn alle scheitern, dann mit einem AggregateError. Und bei hundert Aufrufen gegen dieselbe Schnittstelle gehört eine Begrenzung dazu, sonst antwortet die Gegenstelle mit einer Sperre statt mit Daten.

Fehler verhalten sich anders als im synchronen Code

Innerhalb einer async-Funktion fängt try/catch alles, was aus einem await heraus abgelehnt wird, und finally läuft ebenfalls wie gewohnt. Der Unterschied liegt an der Grenze der Funktion: Ein throw wird beim Aufrufer nicht zu einer Ausnahme, sondern zu einer Ablehnung. Wer eine async-Funktion in ein synchrones try/catch einpackt, fängt deshalb nichts.

Die zweite Grenze ist der Rückgabewert. return laden() im try gibt das Promise weiter und beendet die Funktion sofort, das catch ist zu diesem Zeitpunkt nicht mehr zuständig. Mit return await laden() bleibt die Funktion bis zur Entscheidung aktiv und der Fehler landet im catch. Außerhalb eines try sind beide Schreibweisen gleichwertig.

Am unangenehmsten sind Promises ohne Abnehmer. Ein Aufruf wie speichern(daten); ohne await sieht harmlos aus, verschluckt aber jeden Fehler. Im Browser meldet sich das nur als Uncaught (in promise) in der Konsole, und ein Zuhörer auf das Ereignis unhandledrejection ist der einzige Weg, solche Fälle in die Fehlerüberwachung zu bekommen. In Node beendet dieselbe Situation seit Version 15 den Prozess.

Was async nicht ist

async erzeugt keinen zweiten Ausführungsstrang. Die Nebenläufigkeit entsteht außerhalb deines Codes: Der Netzwerkstapel des Browsers lädt, während JavaScript pausiert, und Node reicht Dateizugriffe und einige Rechenaufgaben an einen internen Thread-Pool weiter. Dein Code bekommt nur die fertigen Ergebnisse zurück, und zwar immer im Hauptstrang.

Daraus folgt die wichtigste Abgrenzung: Rechenarbeit wird durch async nicht nebenläufig. Ein Sortiervorgang über hunderttausend Objekte, eine Bildbearbeitung oder das Parsen eines sehr großen JSON-Dokuments blockieren in einer async-Funktion genauso, wie sie es vorher getan haben. Für solche Aufgaben gibt es im Browser Web Worker und in Node das Modul worker_threads, beide mit eigenem Ausführungsstrang und ohne Zugriff auf das DOM.

Nützlich ist die Unterscheidung zwischen nebenläufig und parallel: Nebenläufig heißt, dass mehrere Vorgänge im selben Zeitraum vorankommen, weil das Warten sich überlappt. Parallel heißt, dass sie im selben Moment rechnen. async und await bringen dir das Erste, niemals das Zweite.

Dazu passende Kurse

In den JavaScript-Kursen bei cmt siehst du an lauffähigen Beispielen, wie await den Ablauf deines Codes verändert .

Wenn du die Rückgabetypen asynchroner Funktionen über mehrere Schichten hinweg absichern willst, führt der Weg über Kurse rund um Typen in JavaScript-Projekten .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Ein sehr gutes, praxisorientiertes und nachhaltiges Seminar. So sollte es immer sein. Vielen Dank.
LimeSurvey - Anwendertraining Teil 1 (Grundlagen)
Die Schulung ist gerade für Einsteiger oder Entwickler*innen mit eingestaubtem Basiswissen sehr hilfreich.
Vue.js 3 Grundkurs
Ich bin sehr glücklich Franz als Trainer gehabt zu haben. Er hat offensichtlich unglaublich viel Wissen zu den Themen, und schafft es, dieses auch interaktiv und verständlich weiterzugeben.
React Komplettausbildung

Häufige Fragen

Wird mein Code durch async und await schneller?
Nur an einer Stelle, nämlich dort, wo mehrere unabhängige Wartevorgänge gleichzeitig laufen können statt hintereinander. Rechenzeit spart async nicht, und eine einzelne Anfrage wird dadurch nicht kürzer. Der Gewinn entsteht ausschließlich durch die Überlappung von Wartezeiten.
Wann brauche ich then und catch überhaupt noch?
Immer dann, wenn du ein Promise weitergibst, ohne selbst auf das Ergebnis zu warten, etwa beim Registrieren eines Ereignisses oder beim Abschließen eines Aufrufs mit catch, um eine unbehandelte Ablehnung zu vermeiden. Außerdem an Stellen, an denen kein await erlaubt ist, zum Beispiel im obersten Bereich eines CommonJS-Moduls.
Warum stoppt Promise.all die anderen Anfragen nicht, wenn eine fehlschlägt?
Weil ein Promise kein Abbrechen kennt. Es beschreibt nur, dass irgendwann ein Ergebnis kommt, und hat keinen Zugriff auf den Vorgang dahinter. Wer eine laufende Anfrage tatsächlich stoppen will, gibt fetch das Signal eines AbortController mit und ruft dessen abort auf.
Kann ich await außerhalb einer async-Funktion benutzen?
In ES-Modulen ja, dort ist await auf oberster Ebene erlaubt und verzögert die Auswertung des Moduls, bis das Promise entschieden ist. In CommonJS-Dateien geht das nicht, dort bleibt der Weg über eine async-Funktion oder über then. Im Browser gilt dasselbe: nur in einem script-Element mit type="module".
Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Webentwicklung-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Asynchrone Abläufe, die du im Kopf durchspielen kannst

In den JavaScript-Kursen bei cmt baust du diese Muster an lauffähigem Code auf und siehst dabei, an welchen Stellen sie im Alltag kippen.