Startseite / Artikel

Async/Await gegen Promises: Was sich im Grunde dahinter unterscheidet

Was unterscheidet sich tatsächlich zwischen async/await und Promises in JavaScript: Ausführungsreihenfolge, Speicherverbrauch, Stack-Traces und wann die rohen Promise-APIs in Node.js weiterhin vorteilhaft sind.

Verlag: REACTAPP.TOP10 Min. Lesezeit2366 Wörter

Quellen: Async/Await vs Promises — The Real Difference Nobody Explains Clearly

Mehr als nur „es handelt sich um syntaktischen Zucker“. Hier erfahren Sie, was wirklich hinter der Kulisse bei Ausführung, Speicherverwaltung, Stack-Traces und Konkurrenz abläuft.

Fragen Sie eine Gruppe voller JavaScript-Entwickler, wie Promises sich von async/await unterscheiden, und die meisten werden denselben Satz wiederholen, den Sie bereits hundertmal gehört haben:

„Async/await ist nichts anderes als eine optische Schicht auf Promises – sie ermöglicht es lediglich, asynchrone Codeblöcke so zu lesen, als würden sie synchron ausgeführt.“

Diese Aussage ist nicht ganz falsch, doch sie ist ein Kurzweg, der mehr verheimlicht als offenbart. async/await als „nur syntaktischen Zucker“ zu bezeichnen, ist ähnlich wie eine Automatikgetriebe als „nur ein dekoratives Panel, das an ein manuelles Getriebe geschraubt ist“, zu beschreiben. Dadurch wird übersehen, wie das zugrunde liegende Mechanismus tatsächlich funktioniert, wie Fehler im System weitergeleitet werden, wie Speicher zugewiesen wird und warum die Wahl eines Stils gegenüber dem anderen das Verhalten der Anwendung im Produktivbetrieb erheblich verändern kann.

Falls die beiden Ansätze wirklich auf jeder Ebene austauschbar wären, betrachten Sie folgende Fragen:

  • Warum sehen Fehlerstapeltraces völlig anders aus, wenn ein Fehler innerhalb eines await stattfindet im Vergleich zu einem innerhalb eines .catch()?
  • Warum zeigen Speichersnapsshots ein unterschiedliches Verhalten hinsichtlich der Datenspeicherung zwischen einer Kette von .then()-Aufrufen und einer äquivalenten async-Funktion?
  • Warum greifen erfahrene Entwickler auch in Codebasen, die ansonsten vollständig auf async/await basieren, weiterhin auf die rohen Promise-APIs zurück?
  • Lassen Sie uns die kurzen Erklärungen beiseitelegen und genauer untersuchen, was tatsächlich im JavaScript-Laufzeitumfeld passiert, wenn asynchrone Codeblöcke ausgeführt werden.

    1. Das grundlegende Denkmodell: Vorantreiben vs. Pause

    Um zu verstehen, warum sich diese beiden Ansätze beim Schreiben und späteren Debuggen so unterschiedlich anfühlen, müssen Sie die jeweils unterschiedlichen Ausführungsmodelle kennen, auf denen sie beruhen.

    RAW PROMISE (.then CHAIN)             ASYNC / AWAIT (COROUTINE)
    ===========================             =========================
    
    +-----------------------+               +-----------------------+
    |  Initiate Task        |               |  Initiate Task        |
    +-----------+-----------+               +-----------+-----------+
                |                                       |
    +-----------v-----------+               +-----------v-----------+
    | Register Callback     |               | Yield & Pause Frame   |
    | (Handler pushed away) |               | (Thread freed up)     |
    +-----------+-----------+               +-----------+-----------+
                |                                       |
    +-----------v-----------+               +-----------v-----------+
    | Main Stack Clears     |               | Resume on Resolution  |
    | (Context lost)        |               | (Context restored)    |
    +-----------------------+               +-----------------------+
    

    Das Promise-Modell: Registrierung von Callbacks (Vorantreiben)

    Eine Promise ist im Grunde ein Objekt, das den zukünftigen Ausgang – Erfolg oder Misserfolg – einer asynchronen Aufgabe repräsentiert.

    Nehmen wir eine typische .then()-Kette:

    function getUserData(userId) {
      return fetchUser(userId)
        .then(user => fetchOrders(user.orderId))
        .then(orders => calculateTotal(orders))
        .catch(err => handleError(err));
    }
    

    Hier wird tatsächlich nichts pausiert. Die Funktion getUserData wird synchron, von Anfang bis Ende, innerhalb von Mikrosekunden ausgeführt. Was sie tatsächlich tut, ist, eine Promise zu erstellen, Callback-Funktionen hinzuzufügen, die ausgelöst werden, sobald diese Promises abgeschlossen sind, und anschließend sofort den Aufrufstapel zu verlassen.

    Später, sobald die Netzwerkanfrage abgeschlossen ist, holt der Event-Loop den registrierten Callback aus der Microtask-Warteschlange und legt ihn auf einen völlig neuen, ansonsten leeren Aufrufstapel. Die ursprüngliche Funktion, die diese Kette in Gang gesetzt hat, ist bereits vollständig verschwunden.

    Das Async/Await-Modell: Koroutinen-Suspension (Pausieren & Fortsetzen)

    async/await wird auf der Basis von ECMAScript-Generatoren und Koroutinenmechanismen implementiert.

    async function getUserData(userId) {
      try {
        const user = await fetchUser(userId);
        const orders = await fetchOrders(user.orderId);
        return calculateTotal(orders);
      } catch (err) {
        handleError(err);
      }
    }
    

    Wenn der Engine eine await-Expression begegnet, wird die Funktion nicht verworfen – stattdessen wird dieser spezifische Stackframe vor Ort ausgesetzt.

    1. Die Steuerung kehrt zum Hauptthread zurück, wodurch dieser für andere Aufgaben frei wird – beispielsweise zum Neupainten der Benutzeroberfläche in einem Browser oder zur Verarbeitung weiterer eingehender Anfragen in Node.js.
    2. Alles bezüglich des lokalen Zustands der Funktion – ihre Variablen, ihr Gültigkeitsbereich, ihre Position – bleibt im Speicher unverändert erhalten.
    3. Sobald die erwartete Promise abgeschlossen ist, gibt die Engine den resultierenden Wert wieder in genau denselben Ausführungskontext zurück und setzt die Ausführung genau dort fort, wo sie aufgehört hat.

    Dieser eine Unterschied – die Übergabe eines getrennten Callbacks zur späteren Ausführung im Vergleich zum Einfrieren und anschließenden Wiederbeleben eines Ausführungsframes – erklärt fast alle praktischen Unterschiede, die man zwischen den beiden Ansätzen feststellen wird.

    2. Die Anatomie eines Call Stack: Warum das Debuggen anders ist

    Das Aufspüren von Fehlern in der Produktion ist bereits einer der schwierigsten Aspekte bei der Arbeit mit JavaScript, und der Unterschied zwischen Promise-Ketten und async/await zeigt sich sofort, sobald man Logdateien durchliest.

    Wie ein Promise-Stack-Trace aussieht

    Nehmen wir eine Promise-Kette, bei der das Versagen mehrere Ebenen tief innerhalb eines Netzwerkaufrufs auftritt:

    function stepOne() {
      return stepTwo();
    }
    
    function stepTwo() {
      return stepThree();
    }
    
    function stepThree() {
      return fetch('https://invalid-url-domain-does-not-exist.xyz')
        .then(res => res.json());
    }
    
    stepOne().catch(err => console.error(err.stack));
    

    Wenn es zu einem Fehler kommt, sieht der resultierende Trace in der Regel so aus:

    TypeError: Failed to fetch
        at stepThree (<anonymous>:10:5)
        at processTicksAndRejections (node:internal/process/task_queues:95:5)
    

    Was fehlt? Sowohl stepOne als auch stepTwo tauchen in diesem Trace überhaupt nicht auf.

    Bis der Netzwerkaufruf tatsächlich fehlschlug, hatten stepOne und stepTwo bereits längst zurückgegeben und waren vom Stack entfernt worden. Der Fehlerbehandlungscode wurde bei einem neuen Microtask-Tick ausgeführt, völlig getrennt von dem Punkt, an dem die Aufrufkette begonnen hatte. Falls stepThree eine gemeinsam genutzte Funktionalität ist, die an fünfzig verschiedenen Stellen in Ihrer Codebasis aufgerufen wird, liefert der Trace keine Möglichkeit, herauszufinden, welcher Aufrufer diesen speziellen Fehler ausgelöst hat.

    Wie ein Async/Await-Stacktrace aussieht

    Schreiben Sie nun dieselbe Logik unter Verwendung von async/await um:

    async function stepOne() {
      return await stepTwo();
    }
    
    async function stepTwo() {
      return await stepThree();
    }
    
    async function stepThree() {
      const res = await fetch('https://invalid-url-domain-does-not-exist.xyz');
      return await res.json();
    }
    
    (async () => {
      try {
        await stepOne();
      } catch (err) {
        console.error(err.stack);
      }
    })();
    

    Hier ist der Trace, den er erzeugt:

    TypeError: Failed to fetch
        at stepThree (<anonymous>:10:21)
        at async stepTwo (<anonymous>:6:10)
        at async stepOne (<anonymous>:2:10)
        at async <anonymous>:15:5
    

    Weil die Engine den Frame nur vorübergehend pausiert anstatt ihn vollständig abzubrechen, können moderne JavaScript-Engines – darunter V8 in Chrome und Node.js – die Historie der asynchronen Aufrufe im Grunde ohne zusätzliche Laufzeitkosten rekonstruieren. Die Engine verfolgt die Fortsetzung des Ablaufs über jeden Punkt der Pause hinweg, sodass der Ablaufverlauf den gesamten Weg zeigt, der zur fehlerhaften Aufrufstelle innerhalb von stepThree führte.

    3. Die „Return Await“-Falle: Ein entscheidender Randfall

    Es besteht seit Langem Uneinigkeit darüber, ob return await promise im Vergleich zu einfachem return promise überflüssig ist.

    Außerhalb eines try/catch-Blocks spart das Direktzurückgeben der Promise einen zusätzlichen Microtask-Tick, wodurch return promise technisch gesehen effizienter ist. Innerhalb eines try/catch-Blocks verändert jedoch dieses eine Wort – await – völlig die Art und Weise, wie Fehler erfasst werden.

    Die fehlerhafte Version

    async function fetchUserData(userId) {
      try {
        // BUG: Returning the promise directly without awaiting it
        return api.getUser(userId);
      } catch (error) {
        console.error("Failed to fetch user, applying fallback...", error);
        return getFallbackUser();
      }
    }
    

    Halb eine Sekunde später, wenn die Promise tatsächlich abgelehnt wird, hat der try/catch-Block seine Ausführung bereits abgeschlossen. Die catch-Klausel wird nie ausgelöst, da sie nicht mehr Teil des aktiven Ausführungspfades ist. Stattdessen entgeht die Ablehnung Ihrer lokalen Fehlerbehandlung völlig und tritt als unverarbeitete Ablehnung irgendwo in der Aufrufkette auf, dort, wo fetchUserData aufgerufen wurde.

    async function fetchUserData(userId) {
      try {
        // CORRECT: Await forces resolution BEFORE exiting the try block
        return await api.getUser(userId);
      } catch (error) {
        console.error("Failed to fetch user, applying fallback...", error);
        return getFallbackUser();
      }
    }
    

    Durch das Hinzufügen von await wird dieses Problem gelöst, da dadurch dem Engine angegeben wird, in dieser Zeile anzuhalten und innerhalb des aktuellen try-Blocks zu bleiben, bis die Promise abgeschlossen ist. Falls sie abgelehnt wird, bleibt die Ausführung lange genug in diesem Kontext, damit der catch-Block sie auffangen kann, anstatt die Funktion bereits vor dem Auftreten des Fehlers zurückzugeben.

    4. Gültigkeitsbereich und gemeinsamer Zustand: Flucht vor „Scope-Gymnastik“

    Nestede Promise-Ketten werden besonders unhandlich, wenn ein späterer Schritt Daten benötigt, die von einem früheren Schritt erzeugt wurden.

    Stellen Sie sich vor, Sie müssen zunächst einen Benutzer abrufen, anschließend sein Profil und schließlich eine Quittung erstellen, die auf Daten aus beiden Schritten angewiesen ist:

    function generateReceipt(userId) {
      return getUser(userId)
        .then(user => {
          return getProfile(user.profileId)
            .then(profile => {
              // You are forced to nest here just to access `user` and `profile` together
              return createReceipt(user, profile);
            });
        });
    }
    

    Um die pyramidenförmige Struktur zu vermeiden, greifen Entwickler oft darauf zurück, Variablen außerhalb der Kette zu deklarieren, damit spätere .then()-Callback-Funktionen darauf zugreifen können:

    function generateReceipt(userId) {
      let tempUser; // Leaking scope variable
    
      return getUser(userId)
        .then(user => {
          tempUser = user;
          return getProfile(user.profileId);
        })
        .then(profile => {
          return createReceipt(tempUser, profile);
        });
    }
    

    async/await beseitigt die Notwendigkeit für diese Workaround-Lösung, denn jede von Ihnen deklarierte Variable bleibt während der gesamten Laufzeit der Operation im selben Funktionsbereich:

    async function generateReceipt(userId) {
      const user = await getUser(userId);
      const profile = await getProfile(user.profileId);
      return createReceipt(user, profile);
    }
    

    Es gibt keine äußere Variable, die im Voraus deklariert werden muss, keine verschachtelte Callback-Struktur und auch keinen Bedarf, die Kette zurückverfolgen zu müssen, um herauszufinden, woher ein Wert stammt.

    5. Leistung und Konkurrenz: Die sequenzielle Falle

    async/await macht den Code leichter lesbar, eröffnet aber gleichzeitig die Tür zu einem kostspieligen Fehler: dem unbeabsichtigten Serialisieren von Operationen, die eigentlich gleichzeitig ausgeführt werden könnten.

    Entwickler, die von Promise-Ketten zu async/await wechseln, schreiben oft etwas in dieser Art, ohne die damit verbundenen Leistungskosten zu erkennen:

    // SLOW: 3 sequential network requests (Waterfall)
    async function loadDashboardData(userId) {
      const user = await fetchUser(userId);       // Takes 200ms
      const posts = await fetchPosts(userId);     // Takes 200ms (waits for user)
      const stats = await fetchStats(userId);     // Takes 200ms (waits for posts)
    
      return { user, posts, stats };              // Total time: 600ms
    }
    

    Weil jedes await die Funktion blockiert, bis dieser Aufruf abgeschlossen ist, wird fetchPosts erst dann starten, wenn fetchUser vollständig beendet ist – obwohl fetchPosts und fetchStats überhaupt keine Abhängigkeit vom Ergebnis von fetchUser haben.

    SEQUENTIAL (Slow Waterfall):
    [-- fetchUser (200ms) --] -> [-- fetchPosts (200ms) --] -> [-- fetchStats (200ms) --] = 600ms
    
    CONCURRENT (Optimized):
    [-- fetchUser (200ms) --]
    [-- fetchPosts (200ms) --]
    [-- fetchStats (200ms) --] = 200ms (Total)
    

    Die Lösung: async/await mit Promise-Combinatoren kombinieren

    async/await ist kein Ersatz für die Promise-Hilfsmethoden – es soll vielmehr mit diesen zusammen verwendet werden.

    // FAST: All 3 requests start immediately in parallel
    async function loadDashboardData(userId) {
      const [user, posts, stats] = await Promise.all([
        fetchUser(userId),
        fetchPosts(userId),
        fetchStats(userId)
      ]);
    
      return { user, posts, stats }; // Total time: ~200ms
    }
    

    6. Schleifen und Iteration: Die Falle der Array-Methode

    Eine häufige Quelle für Fehler in aktuellen JavaScript-Codebasen entsteht durch die Kombination von asynchronen Callbacks mit Array-Methoden wie .forEach(), .map() oder .filter().

    async function saveUsers(users) {
      users.forEach(async (user) => {
        await db.users.insert(user);
      });
    
      console.log("All users saved!"); // LIES: This logs BEFORE the database calls finish!
    }
    

    Dies scheitert, weil Array.prototype.forEach von Natur aus synchron ist – es prüft niemals, was die Callback-Funktion zurückgibt. Auch wenn man eine asynchrone Funktion an forEach übergeben muss, wodurch jede Aufrufung einen Promise erzeugt, verfügt forEach über kein Mechanismus, auf diese Promises zu warten; es geht direkt zur nächsten Iteration über.

    Es gibt zwei korrekte Alternativen, je nachdem, was man tatsächlich benötigt.

    Option A führt die Arbeit einzeln mit einer for...of-Schleife aus, was sinnvoll ist, wenn die Reihenfolge wichtig ist oder man vermeiden möchte, eine rate-limitede API zu überlasten:

    async function saveUsersSequentially(users) {
      for (const user of users) {
        await db.users.insert(user);
      }
      console.log("All users saved safely in order.");
    }
    

    Option B führt alle Operationen gleichzeitig aus, indem .map() mit Promise.all kombiniert wird – das eignet sich für Fälle, in denen die Operationen voneinander unabhängig sind:

    async function saveUsersConcurrently(users) {
      await Promise.all(users.map(user => db.users.insert(user)));
      console.log("All users saved simultaneously.");
    }
    

    7. Wenn rohe Promises immer noch besser sind

    async/await sollte Ihre erste Wahl für lineare Geschäftslogik sein, es gibt jedoch bestimmte architektonische Situationen, in denen die direkte Verwendung von Promises die bessere Wahl ist.

    Die erste Situation betrifft Hintergrundaufgaben, bei denen man einen Nebeneffekt auslösen möchte, ohne dass der Aufrufer darauf warten muss:

    async function handleUserSignup(req, res) {
      const user = await db.createUser(req.body);
    
      // Non-blocking: Fire and handle errors without awaiting
      sendWelcomeEmail(user.email).catch(err => {
        logger.error("Failed to send welcome email in background", err);
      });
    
      // Respond immediately without waiting for the email service
      return res.json({ status: "success", user });
    }
    

    Die zweite Situation betrifft das Verwalten komplexerer Konkurrenzszenarien, bei denen die speziell dafür entwickelten Promise-Combinierungsmethoden ein zielgerichtetes Verhalten bieten:

    • Promise.race() ermöglicht es, langsame Netzwerkaufrufe durch einen Timeout abzubrechen.
    • Promise.allSettled() führt mehrere unabhängige Aufgaben aus und berichtet über alle von ihnen, sowohl Erfolge als auch Fehler.
    • Promise.any() wird bereits dann erfüllt, wenn einer der gegebenen Promises erfolgreich ist – was nützlich ist für Fallback-Strategien in mehreren Regionen oder Quellen.
    // Timeout a network request after 5 seconds
    const data = await Promise.race([
      fetch('https://api.example.com/data').then(r => r.json()),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Request timed out')), 5000)
      )
    ]);
    

    Das dritte Szenario besteht darin, eine Brücke um herkömmliche, auf Callbacks basierende APIs zu bauen – beispielsweise Node’s fs.readFile oder ein älteres SDK –, wofür explizit eine new Promise() erstellt werden muss:

    function readConfigFile(path) {
      return new Promise((resolve, reject) => {
        legacyLib.readFile(path, (err, data) => {
          if (err) return reject(err);
          resolve(data);
        });
      });
    }
    

    8. Zusammenfassende Vergleich

    Das Urteil

    async/await ist nicht nur eine kosmetische Syntax, die über Promises gelegt wird.

    Auch wenn es im Hintergrund weiterhin auf Promises angewiesen ist, um den Abschluss von Operationen darzustellen, verändert es asynchrone Codestrukturen von einer Kette von Callback-Registrierungen zu etwas, das eher einer unterbrechbaren Koroutine ähnelt. Genau diese Veränderung ermöglicht sinnvolle Stacktraces, eine normale Fehlerbehandlung auf Sprachebene mittels try/catch sowie ein einfaches lexikales Scoping.

    Um sich mit modernem asynchronen JavaScript vertraut zu machen, geht es letztendlich darum zu erkennen, wann der lesbare, lineare Stil von async/await zu Ihrer sequenziellen Logik passt, und wann Sie auf reine Promise-Combinatoren – Promise.all, Promise.allSettled, Promise.race – zurückgreifen sollten, um Konkurrenzen effektiv zu verwalten.