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).
- Promises provide a standardized way to represent the eventual completion or failure of an asynchronous operation.
- Generators (
function*andyield) allow a function to pause its execution at a specific point and yield control back to the caller, maintaining its internal state until execution is resumed via.next().
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:
- 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). - 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:
- Evaluation: The engine evaluates the expression
following
await. If it is not a Promise, it implicitly transforms it into one viaPromise.resolve(expression). - Suspension: The engine pauses the execution of the
current
asyncfunction. The function’s local variables, arguments, and current execution pointer are saved in memory. - Yielding Control: The
asyncfunction immediately returns an unresolved Promise to its outer caller. Control returns to whatever called theasyncfunction, allowing the call stack to continue executing synchronous code. - 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:
- Once the awaited Promise settles, a resumption callback is placed into the microtask queue.
- The JavaScript Event Loop continues executing whatever synchronous tasks remain on the Call Stack.
- When the Call Stack is empty, the Event Loop checks the Microtask
Queue before moving on to rendering or macrotasks (such as
setTimeout). - The microtask executes, restoring the saved execution context of the
asyncfunction. - 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.