Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 42

Async/Await: Syntactic Sugar for Promises and Generators

Recommended resources

Hello! Welcome back.

In our previous lesson, we explored generator functions, discovering their ability to pause (yield) and resume (.next()) execution. We highlighted the crucial concept of two-way communication, where the caller can pass data back into the paused generator via .next(value). We concluded by noting this mechanism is a key ingredient for async/await.

Today, we'll complete that picture. This lesson connects all the dots, showing precisely how async/await is implemented as "syntactic sugar" over the Promises and generators you've already learned about. By the end, you will be able to describe the underlying mechanics that make the simple async/await syntax possible, fulfilling a key part of your goal to understand the "why" behind JavaScript's core features.

The Core Idea: Syntactic Sugar

First, let's solidify what "syntactic sugar" means in this context. It means async/await doesn't introduce fundamentally new capabilities to the JavaScript engine. Instead, it provides a cleaner, more readable syntax for something we could already do with existing features—namely, Promises and, as we'll see, generators.

Don't Imitate, Understand #2 - Promises, Async, and Await

The video 'Don't Imitate, Understand #2' offers a great high-level summary of this concept.

Please watch from 36:50 to 38:06. The key takeaway is the definition of syntactic sugar: features designed to make code more efficient or understandable without adding new, unique functionality.


The Conceptual Model: Generators Orchestrating Promises

So, how does this work? The core model is a marriage of our last two topics:

  1. An async function behaves like a generator function that can be paused.
  2. The await keyword is the new yield. Instead of yielding arbitrary values, it specifically yields a Promise.
  3. A hidden "runner" function (provided by the JavaScript engine) takes the yielded Promise, waits for it to resolve using .then(), and then passes the resolved value back into the generator by calling .next(), resuming its execution.

This side-by-side comparison makes the relationship clear:

Async/Await vs. Generators and Promises
On the left is a standard `async` function using `await`. On the right is the conceptual equivalent: a generator function using `yield`, which is executed by a `runner` function that manages the asynchronous flow.

Let's walk through building this mental model piece by piece.

Internals of async / await in JavaScript

The article 'Internals of async / await in JavaScript' by Akash Hamirwasia provides a step-by-step guide to re-implementing async/await logic. We will follow its logic to build our understanding.

Please read from the section 'Hint #1: Looking at pausing and resuming a function execution' through 'Hint #3: Making promise’s resolved data available to the generator'. As you read, focus on how each step builds upon the last: Hint #1: Recognizes that generators provide the necessary pause/resume capability. Hint #2: Connects the two concepts by having the generator yield the promise it needs to wait for. Hint #3: Uses the it.next(resolvedData) pattern we learned in the last lesson to pass the promise's result back into the generator.

After reading, you should see the fundamental pattern: a yield statement exposes the Promise to the outside world, and the outside world uses .then() to schedule the call to .next() that resumes the function.


From Model to Implementation: Building the Async Runner

Now that we have the conceptual blueprint, let's build the "runner" function that automates this process. The runner's job is to take a generator function and drive it to completion, handling any Promises it yields along the way. This is often called a "co-routine."

A runner needs to:

  1. Initialize the generator.
  2. Create a recursive step function to advance the generator.
  3. Inside step, call generator.next().
  4. Check if the generator is done. If so, resolve the final value.
  5. If not done, take the yielded value (which we assume is a Promise), and use .then() to recursively call step with the resolved value.
  6. Handle errors by using generator.throw() to send rejections back into the generator.

This process is demonstrated perfectly in the following video.

Building Async/Await FROM SCRATCH using Generators

We'll continue with the 'Building Async/Await FROM SCRATCH' video from Low Byte Productions. Having already seen its explanation of generators, we'll now watch the core part where it constructs the asyncFN runner.

Please watch from 08:23 to 14:34. This segment is dense but incredibly insightful. Pay close attention to: The recursive interpreter function (our step function). How it uses value.then() to schedule the next call to itself. The crucial role of generator.throw(error) to propagate promise rejections into the generator, allowing try...catch blocks to work as expected.

This implementation reveals the true genius of the async/await design. It's a beautiful abstraction that hides a sophisticated co-routine, which you now understand how to build from first principles. Transpilers like Babel use a very similar approach to convert async/await syntax into ES6-compatible code (generators + Promises) for older browsers.


Under the Hood: The Native V8 Implementation

While the generator-based runner is a conceptually accurate model and is what transpilers do, it's important to know that native JavaScript engines like V8 have a more direct and optimized implementation. Given your interest in the low-level details, let's take a brief look.

When the V8 engine encounters an await:

  1. Suspend Execution: It doesn't just pause a generator; it suspends the execution context of the async function. The function's state (local variables, etc.) is saved.
  2. Return to Event Loop: Control is immediately returned to the event loop, allowing other synchronous code to run.
  3. Queue a Microtask: When the awaited Promise settles, the engine doesn't call .then() in the same way our runner did. Instead, it places a job in the microtask queue. This job contains the logic to resume the suspended function.
  4. Resume Execution: After the current synchronous script finishes and the call stack is empty, the event loop processes the microtask queue. The job runs, restoring the function's execution context and feeding it the resolved value of the Promise.

This process is managed internally by highly optimized C++ code within V8, which represents async functions with special objects and uses dedicated bytecode for suspending and resuming.

Deep Dive: What Actually Runs When You Write 'await' in JavaScript?

The article 'Deep Dive: What Actually Runs When You Write 'await' in JavaScript?' provides an excellent summary of this native implementation.

Please read the sections 'Level 2: Generator Functions', 'Level 3: JavaScript Runtime & Microtasks', and 'Level 5: V8's C++ Implementation'. You don't need to understand the C++ code itself, but focus on the concepts: How transpilers use the generator model we just discussed. The role of the microtask queue in scheduling the resumption of the async function. The existence of internal V8 objects (JSAsyncFunctionObject) and bytecode (SuspendGenerator, ResumeGenerator) that manage this process natively.

Understanding this native path is key. It clarifies why async/await is not just a simple library function but a deep-seated language feature tied directly into the JavaScript runtime's concurrency model.


Conclusion

In this lesson, we demystified async/await by peeling back the syntactic sugar to reveal the machinery underneath. You now have a multi-layered understanding of this feature, from the high-level syntax down to its conceptual and native implementations.

Key Takeaways:

  • Syntactic Sugar: async/await provides a synchronous-looking syntax for asynchronous operations that are fundamentally based on Promises.
  • Conceptual Model: The behavior of async/await can be perfectly replicated using a generator function that yields Promises, driven by a "runner" function that uses .then() to resume the generator with the resolved value.
  • Error Handling: The runner uses generator.throw() to inject promise rejections back into the generator, which is why try...catch blocks work seamlessly with await.
  • Native Implementation: In modern JavaScript engines, this process is natively optimized and integrated with the event loop, using the microtask queue to schedule the resumption of suspended functions.

What's Next?

With this deep understanding of how individual asynchronous operations are managed, you are now equipped to handle more complex scenarios. In our next lesson, we will explore common asynchronous patterns, such as running multiple promises in parallel with Promise.all() and handling race conditions with Promise.race().