How Async Await Works Under the Hood in JavaScript

The async and await keywords in JavaScript provide a clean, readable syntax for handling asynchronous operations without nesting callbacks or chaining .then() handlers. Under the hood, this syntax is not a new concurrency model; it is syntactic sugar built on top of native JavaScript Promises and Generator functions. This article breaks down the internal mechanics of async/await, exploring how JavaScript engines use execution contexts, the microtask queue, and state machines to suspend and resume functions seamlessly without blocking the main execution thread.

The Foundation: Promises and Generators

To understand async/await, you must understand that the engine converts it into a combination of Promises and Generators (coroutines).

An async function behaves essentially like an automatically managed generator function wrapped in a promise-resolving runner.


What Happens with the async Keyword?

When you declare a function with async, the JavaScript engine modifies two fundamental behaviors:

  1. Automatic Promise Wrapping: The function is guaranteed to return a Promise. If the function returns a primitive value or an object, the engine implicitly wraps it using Promise.resolve(returnValue).
  2. Asynchronous Execution Context: The engine sets up the function’s internal execution context to allow it to be suspended and resumed without destroying its local variable scope.
async function getData() {
  return "data loaded";
}

// Under the hood, this is equivalent to:
function getData() {
  return Promise.resolve("data loaded");
}

What Happens with the await Keyword?

The await keyword can only be used inside an async function (or at the top level in modern ES modules). When the engine encounters await expression:

  1. Evaluation: The engine evaluates the expression following await. If it is not a Promise, it implicitly transforms it into one via Promise.resolve(expression).
  2. Suspension: The engine pauses the execution of the current async function. The function’s local variables, arguments, and current execution pointer are saved in memory.
  3. Yielding Control: The async function immediately returns an unresolved Promise to its outer caller. Control returns to whatever called the async function, allowing the call stack to continue executing synchronous code.
  4. Attaching Handlers: The engine attaches an internal fulfillment handler and rejection handler to the awaited Promise.

The Event Loop and Microtask Queue Integration

When the awaited Promise resolves (or rejects), the JavaScript runtime does not immediately resume the paused async function synchronously. Instead, it leverages the Microtask Queue:

  1. Once the awaited Promise settles, a resumption callback is placed into the microtask queue.
  2. The JavaScript Event Loop continues executing whatever synchronous tasks remain on the Call Stack.
  3. When the Call Stack is empty, the Event Loop checks the Microtask Queue before moving on to rendering or macrotasks (such as setTimeout).
  4. The microtask executes, restoring the saved execution context of the async function.
  5. The awaited value is injected back into the function, and execution resumes from the exact line where it was suspended.

Conceptual Implementation: The Runner Pattern

Before async/await was native to the language, developers used libraries like co or Babel transpilers to achieve the same result using generators and promises.

Under the hood, an async function functions similarly to this engine-level implementation:

function runner(generatorFn) {
  return function (...args) {
    const gen = generatorFn(...args);

    return new Promise((resolve, reject) => {
      function step(nextFn) {
        let result;
        try {
          result = nextFn();
        } catch (error) {
          return reject(error);
        }

        if (result.done) {
          return resolve(result.value);
        }

        Promise.resolve(result.value)
          .then((value) => step(() => gen.next(value)))
          .catch((err) => step(() => gen.throw(err)));
      }

      step(() => gen.next());
    });
  };
}

Error Handling Under the Hood

When an awaited Promise rejects, the engine handles the rejection by throwing an exception inside the suspended function context at the point of the await expression.

If the await statement is wrapped inside a try/catch block, the catch block intercepts the error synchronously from the perspective of the function. If the error is not caught inside the function, the Promise returned by the async function itself transitions to the rejected state with that error.