Veröffentlicht am
4.5.2026
Callback-Höllen - lieber nicht!
Das folgende Beispiel aus der Küche veranschaulicht eine Callback-Hölle ziemlich gut.
Callback-Höllen gehören gerade in der JavaScript-Welt zu einem besonders lästigen Übel und wenn man nicht gerade viel Zeit hat, ist sie sehr schnell implementiert. Doch was verbirgt sich dahinter genau?
Zunächst einmal muss man wissen, warum solche Callback-Höllen entstehen. Meistens passiert das aufgrund eines Aufrufes einer asynchronen Funktion und dem anschließenden Warten auf das Ergebnis. Um dieses Beispiel einmal bildlich zu verdeutlichen, gehen wir kurz in die Küche und stellen uns vor, wir wollen uns etwas zu Essen zubereiten. Dieses Gericht ist zudem auch noch ziemlich komplex und erfordert laut dem Rezept viele Arbeitsschritte. Wir müssen den Ofen vorheizen, benötigen mindestens drei Töpfe und zwei Saucen, Wasser muss vorgekocht werden und einige Zutaten müssen vorab aus dem Tiefkühlfach aufgetaut werden. Würde man das alles synchron erledigen - d.h. nur einen Arbeitsschritt und der nächste wird erst angefangen, wenn der vorherige fertig ist - dann würde die Zubereitung dieser Speise locker zwei Stunden oder gar länger dauern. In der Küche macht man das allerdings selten so, sondern man macht zwei bis drei Dinge nebenbei. Während der Ofen vorgeheizt wird, kann man schon mit dem Auftauen der Zutaten aus dem Tiefkühlfach beginnen oder die Saucen vorbereiten. Wenn der Ofen dann vorgeheizt wurde, können die aufgetauten Zutaten dort hinein geschoben werden und man kann bequem mit den nächsten Schritten weitermachen. Genau so etwas ermöglicht auch JavaScript mithilfe von asynchronen Funktionen.
Wenn wir das obige Beispiel in JavaScript mit klassischen Callbacks überführen, ergibt sich schnell so eine Callback-Hölle:
function cookDinner() {
console.log("Starting meal preparation...");
// Step 1: Preheat the oven
preheatOven(function (ovenError, ovenStatus) {
if (ovenError) {
console.error("The oven is broken!");
} else {
console.log(ovenStatus); // "Oven is hot"
// Step 2: Get ingredients from the freezer and defrost
getIngredientsFromFreezer(function (freezerError, ingredients) {
if (freezerError) {
console.error("The freezer is empty!");
} else {
console.log("Ingredients ready: " + ingredients);
// Step 3: Boil water for pasta/rice
boilWater(function (waterError, waterStatus) {
if (waterError) {
console.error("The stove is malfunctioning!");
} else {
console.log(waterStatus); // "Water is bubbling"
// Step 4: Prepare the sauces
prepareSauces(ingredients, function (sauceError, sauce) {
if (sauceError) {
console.error("The sauce is burnt!");
} else {
console.log("Sauce ready: " + sauce);
// Step 5: Put everything in the oven
bakeDish(ingredients, sauce, function (bakeError, meal) {
if (bakeError) {
console.error("The food is charred!");
} else {
// Step 6: Finally serve the meal
serveMeal(meal, function (serveStatus) {
console.log(serveStatus); // "Bon appétit!"
});
}
});
}
});
}
});
}
});
}
});
}
cookDinner();
Wie ihr anhand des Beispiels seht, wird das sehr schnell unübersichtlich und unwartbar. In der Praxis möchte man zum Beispiel eine API aufrufen, doch diese ist vielleicht nicht erreichbar und erst wenn der Aufruf der API erfolgt ist, sollen weitere Aktionen wie ein zweiter oder gar dritter Aufruf einer anderen API ausgeführt werden. Dann stauen sich solche Funktionen sehr schnell auf und man verliert sofort den Überblick.
Lösung: Promises mithilfe von asynchronen Funktionen
JavaScript erlaubt es dem Entwickler, Funktionen als asynchron zu deklarieren und so genannte Promises zurück zu geben. Ein Promise ist ein Objekt, welches als Platzhalter für einen Wert dient, der zwar noch nicht bekannt ist, aber in der Zukunft definitiv verfügbar sein wird - und wenn es sich dabei um eine Fehlermeldung handelt. Um das wieder mit unserem Beispiel zu untermauern: Stell dir vor, du bestellst im Restaurant ein Gericht und bekommst einen Beeper in die Hand. Das Gericht ist noch nicht fertig, aber der Beeper wird dir in jedem Fall eine Information geben - entweder wenn das Essen fertig ist oder wenn etwas schief gelaufen ist (z.B. hat die Küche nachträglich festgestellt, dass einige Zutaten für dein Gericht nicht mehr vorrätig sind). Dieser Beeper wäre das Promise.
Ein Promise-Objekt nimmt in JavaScript einen von drei Zuständen an:
- Pending: Die asynchrone Operation läuft noch, wie z.B. die Zubereitung deines bestellten Gerichtes
- Fulfilled: Die asynchrone Operation war erfolgreich und der Wert ist nun verfügbar, wie z.B. dein fertig zubereitetes Essen
- Rejected: Die Operation ist fehlgeschlagen, wie z.B. die Zutaten sind in der Küche für dein bestelltes Essen nicht mehr vorrätig
Asynchrone Funktionen geben immer einen Promise zurück! Das hat den großen Vorteil, dass man mit dem restlichen Code weiterarbeiten kann, ohne auf das Ergebnis warten zu müssen. Zwar ist JavaScript selber nur Single-Threaded, d.h. es kann eigentlich nur eine Sache gleichzeitig erledigen, aber der interne Event Loop sorgt dafür, dass asynchrone Funktionen trotzdem ausgeführt werden können. Dabei landet der normale sofort ausführbare Code im so genannten Call Stack und asynchrone Aufgaben werden an Web APIs ausgelagert. Wenn die Operation abgeschlossen ist, landet das Ergebnis in der Callback Queue und der Event Loop prüft, ob der Call Stack leer ist. Wenn ja, wird das nächste fertige Paket aus der Warteschlange entnommen und ausgeführt. Ohne asynchrone Funktionen wäre das moderne Web nahezu unbrauchbar und man müsste entweder ewig auf die Ergebnisse warten oder die Website würde sogar abstürzen.
Die obige Callback-Hölle überführen wir nun in eine asynchrone Funktion:
async function cookDinner() {
console.log("Starting meal preparation...");
try {
// Step 1: Preheat the oven
const ovenStatus = await preheatOven();
console.log(ovenStatus); // "Oven is hot"
// Step 2: Get ingredients and defrost
const ingredients = await getIngredientsFromFreezer();
console.log("Ingredients ready: " + ingredients);
// Step 3: Boil water
const waterStatus = await boilWater();
console.log(waterStatus); // "Water is bubbling"
// Step 4: Prepare the sauces
const sauce = await prepareSauces(ingredients);
console.log("Sauce ready: " + sauce);
// Step 5: Bake the dish
const meal = await bakeDish(ingredients, sauce);
// Step 6: Serve
const serveStatus = await serveMeal(meal);
console.log(serveStatus); // "Bon appétit!"
} catch (error) {
console.error("Cooking failed:", error);
}
}
cookDinner();
Die Funktionen der einzelnen Schritte werden natürlich ebenfalls als asynchrone Funktionen definiert. Das Schlüsselwort await kann man nur in einer asynchronen Funktion verwenden. Aber durch den aufgeräumten Aufbau ergibt sich eine erheblich übersichtlichere und wartungsfreundlichere Struktur des Codes. Kommt es bei einem Schritt (z.B. beim dritten Schritt) zu einem Fehler, bricht JavaScript die weitere Ausführung der Funktionen ab und springt in den catch-Block, wo der Fehler in der Konsole rot ausgegeben wird. Genau das ist der Vorteil mit dem try-catch-Block in Kombination von await. Diese Funktionen laufen jedoch noch sequenziell.
Nebenläufige Verarbeitung mithilfe von Promise.all
Man kann JavaScript auch sagen, dass asynchrone Funktionen tatsächlich nebenläufig abgearbeitet werden können. Dazu übergibt man der all()-Funktion des Promise-Objektes ein Array mit den parallel zu erledigenden Funktionen:
// Step 1, 2 and 3
const [ovenStatus, ingredients, waterStatus] = await Promise.all([
preheatOven(),
getIngredientsFromFreezer(),
boilWater(),
]);
// Step 4
const sauce = await prepareSauces(ingredients);
// Step 5
// [...]
Hier werden die Funktionen in Zeile 3, 4 und 5 sofort nacheinander gestartet und laufen dann nebenläufig. Doch aufgepasst: Sobald eine der Funktion scheitert, bricht Promise.all sofort ab und wirft einen Fehler aus. Die anderen Ergebnisse werden dann ignoriert. Verwende Promise.all() nur dann, wenn du alle Ergebnisse zwingend benötigst, um weitermachen zu können.