Warum es asynchrones JavaScript gibt
Computer erledigen viele Dinge langsam: das Lesen von Dateien, Datenbankabfragen oder das Warten auf die Antwort eines Servers. Wenn JavaScript bei jedem dieser Vorgänge anhalten und warten würde, würde die Seite einfrieren und die Nutzer würden die Seite verlassen. Asynchrone Programmierung ermöglicht es der Sprache, weiterzuarbeiten, während langsame Aufgaben an anderer Stelle ausgeführt werden.
Das passende mentale Modell hierfür ist ein Restaurant. Ein einziger Kellner nimmt Ihre Bestellung auf, gibt sie an die Küche weiter und bedient sofort den nächsten Tisch. Wenn Ihr Essen fertig ist, läutet die Küche eine Glocke und der Kellner bringt es an den Tisch. Der Kellner steht niemals in der Küche und schaut dem Essen beim Kochen zu. JavaScript ist dieser Kellner und der event loop ist die Glocke.
Der Call Stack und der Event Loop
JavaScript besitzt einen einzigen Call Stack. Jeder Funktionsaufruf pusht einen Frame auf den Stack; sobald die Funktion zurückkehrt, wird der Frame wieder entfernt (pop). Da es nur einen Stack gibt, kann immer nur eine Operation gleichzeitig ausgeführt werden.
Asynchrone APIs werden von der Host-Umgebung bereitgestellt, nicht von der Sprache selbst. Wenn Sie setTimeout, fetch oder einen Datei-Read aufrufen, übernimmt der Browser oder Node.js das Warten. Sobald die Arbeit abgeschlossen ist, wird der entsprechende Callback in eine Queue eingereiht. Der Event Loop prüft kontinuierlich: Ist der Call Stack leer? Wenn ja, wird der nächste Callback aus der Queue genommen und ausgeführt.
// order.js
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
// start, end, promise, timeout
Zwei Details sind hierbei entscheidend. Erstens wird synchroner Code immer vollständig ausgeführt, bevor irgendein Callback startet. Zweitens gibt es zwei Queues: Microtasks (Promise-Callbacks) werden unmittelbar nach dem aktuellen Task ausgeführt, noch vor den eigentlichen Tasks (Timer, I/O). Aus diesem Grund loggt promise vor timeout.
Callbacks
Die ursprüngliche Methode zur Verarbeitung asynchroner Ergebnisse ist der Callback – eine Funktion, die man übergibt, damit sie zu einem späteren Zeitpunkt aufgerufen wird.
// callback.js
function getUser(id, callback) {
setTimeout(() => {
callback({ id, name: "Ada" });
}, 300);
}
getUser(1, (user) => {
console.log(user.name);
});
Callbacks funktionieren zwar, lassen sich aber schlecht kombinieren. Wenn jeder Schritt vom vorherigen abhängt, entstehen Verschachtelungen, und das Ergebnis ist die sogenannte callback hell: Code, der diagonal über den Bildschirm wandert und die Fehlerbehandlung extrem mühsam macht.
// hell.js
getUser(1, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
// three levels deep and counting
});
});
});
Promises
Ein Promise ist ein Objekt, das einen Wert repräsentiert, der möglicherweise noch nicht existiert. Es befindet sich in einem von drei Zuständen:
- pending — die Operation läuft noch.
- fulfilled — sie wurde erfolgreich mit einem Wert abgeschlossen.
- rejected — sie ist mit einer Fehlermeldung fehlgeschlagen.
Sobald ein Promise abgeschlossen ist, ändert es seinen Zustand nicht mehr. Über then, catch und finally hängen Sie Handler an.
// promise.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) resolve({ id, name: "Ada" });
else reject(new Error("Invalid id"));
}, 300);
});
}
getUser(1)
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message))
.finally(() => console.log("done"));
Der entscheidende Vorteil ist das Chaining. Jedes then gibt ein neues Promise zurück. Anstatt die Aufrufe zu verschachteln, können Sie die Sequenz flach halten und jeden Fehler in einem einzigen catch am Ende abfangen.
// chain.js
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error("Something failed:", error));
Promises kombinieren
Wenn mehrere asynchrone Operationen involviert sind, bietet der Promise-Konstruktor Kombinatoren an.
// combine.js
const [user, posts, settings] = await Promise.all([
getUser(1),
getPosts(1),
getSettings(),
]);
const results = await Promise.allSettled([getUser(1), getUser(-1)]);
const fastest = await Promise.race([getFromCache(), getFromNetwork()]);
const firstSuccess = await Promise.any([mirrorA(), mirrorB()]);
Promise.all führt alles parallel aus und lehnt ab (reject), wenn eine einzige Promise abgelehnt wird. Promise.allSettled wartet auf alle und berichtet über jedes Ergebnis. Promise.race löst sich mit der ersten Promise auf, die einen Status erreicht (settle), und Promise.any löst sich mit der ersten auf, die erfolgreich ist (resolve). Die Wahl des richtigen Kombinators verändert dein Fehlerverhalten dramatisch.
async und await
async und await sind Syntax-Erweiterungen, die auf Promises aufbauen. Wenn eine Funktion als async markiert wird, gibt sie immer ein Promise zurück. Innerhalb dieser Funktion pausiert await die Ausführung, bis ein Promise abgeschlossen ist, und setzt sie dann mit dem aufgelösten Wert fort – ohne den Rest des Programms zu blockieren.
// await.js
async function loadDashboard() {
try {
const user = await getUser(1);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load dashboard", error);
throw error;
}
}
Das liest sich wie synchroner Code, was genau das Ziel ist. Fehler werden mit gewöhnlichen try/catch-Blöcken abgefangen, und finally läuft weiterhin. Da die Funktion ein Promise zurückgibt, können Aufrufer dieses await oder wie gewohnt .then verketten.
Sequenziell vs. parallel
Ein subtiler, aber kostspieliger Fehler besteht darin, unabhängige Aufgaben nacheinander mit await abzuarbeiten. Jedes await wartet auf das vorherige, sodass die Gesamtzeit die Summe aller Einzelzeiten ist. Wenn die Aufgaben nicht voneinander abhängen, sollten sie gleichzeitig gestartet werden.
// parallel.js
// Sequential — slow, ~600ms
const a = await slowTask("a");
const b = await slowTask("b");
// Parallel — fast, ~300ms
const [x, y] = await Promise.all([slowTask("x"), slowTask("y")]);
Nutzen Sie sequenzielle await-Aufrufe nur dann, wenn ein späterer Schritt tatsächlich ein Ergebnis eines vorherigen Schritts benötigt – zum Beispiel, wenn erst ein Benutzer abgerufen werden muss, bevor dessen Posts geladen werden können.
Fehlerbehandlung in async-Code
Abgelehnte Promises, die nicht abgefangen werden, werden zu unhandled rejections. Diese können Node.js-Prozesse zum Absturz bringen und die Konsole in Browsern überfluten.
// errors.js
try {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
} catch (error) {
showMessage("Could not load data. Please try again.");
} finally {
hideSpinner();
}
Bevorzuge try/catch um awaited-Aufrufe, wirf Error-Objekte (keine Strings) und fange Fehler immer ab oder wirf sie bewusst erneut. Der Error Handling Guide geht detaillierter auf Strategien und benutzerorientierte Fehlermeldungen ein.
Häufige Fallstricke
awaitvergessen. Die Funktion gibt ein Promise zurück, nicht den Wert, sodass die nächste Zeile zu früh ausgeführt wird.forEachbei async Callbacks.forEachignoriert zurückgegebene Promises; verwenden Sie stattdessenfor...ofoderPromise.allzusammen mitmap.- Awaiting in einer Schleife, obwohl Parallelisierung möglich wäre. Es funktioniert zwar, ist aber unnötig langsam.
- Fehlendes Return innerhalb von
then. Ein fehlendesreturnunterbricht die Kette und führt zum Verlust des Wertes. - Annahme einer festen Reihenfolge. Nur
awaitund die Microtask-Reihenfolge garantieren die Sequenz; konkurrierende Tasks schließen ab, wann immer sie fertig sind. - Fehler verschlucken durch einen leeren
catch-Block, wodurch der Bug verborgen bleibt, den Sie eigentlich hätten sehen müssen.
Best Practices
- Bevorzuge
async/awaitgegenüber langen.then-Ketten für eine bessere Lesbarkeit. - Führe unabhängige Aufgaben parallel mit
Promise.alloderPromise.allSettledaus. - Behandle Ablehnungen (Rejections) immer, und wenn es nur dazu dient, sie zu loggen und erneut zu werfen.
- Halte async-Funktionen klein und weise ihnen jeweils nur eine einzige Verantwortung zu.
- Nutze
Promise.allSettled, wenn Teilergebnisse akzeptabel sind. - Brich Aufgaben, die nicht mehr benötigt werden, mit einem
AbortControllerab. - Zeige Lade- und Fehlerzustände an, damit die Nutzer verstehen, was gerade passiert.
Wie geht es weiter?
Du verfügst nun über das komplette Toolkit für asynchrone Programmierung. Setze es mit der Fetch API in die Praxis um, wo Promises und await jede Netzwerkanfrage steuern, und mache deinen Code mit den Mustern aus dem Bereich Error Handling robuster.