Ever wondered what's really happening when you write async and await in JavaScript? Let's peel back the layers and explore this feature from the high-level syntax all the way down to the C++ implementation in V8.
Table of Contents
- The Surface: What You Write
- Level 1: Desugaring to Promises
- Level 2: Generator Functions
- Level 3: JavaScript Runtime & Microtasks
- Level 4: V8's C++ Implementation
- Putting It All Together
1. The Surface: What You Write
Let's start with familiar async/await code:
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
}
This looks synchronous but behaves asynchronously. Magic? Not quite.
2. Level 1: Desugaring to Promises
The async/await syntax is syntactic sugar over Promises. Here's what your async function really looks like:
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${userId}`)
.then(response => {
return response.json();
})
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
}
Key Transformations:
-
async function→ returns a Promise automatically -
await expression→.then()on the promise -
return value→resolve(value) - Thrown errors →
reject(error)
But this doesn't explain how the function "pauses" at each await. That's where generators come in.
3. Level 2: Generator Functions
Under the hood, async functions are implemented using generator functions. Here's a closer approximation:
function* fetchUserDataGenerator(userId) {
const response = yield fetch(`/api/users/${userId}`);
const data = yield response.json();
return data;
}
// Wrapped in a runner
function asyncToGenerator(generatorFunc) {
return function(...args) {
const gen = generatorFunc(...args);
return new Promise((resolve, reject) => {
function step(nextFunc) {
let result;
try {
result = nextFunc();
} catch (e) {
reject(e);
return;
}
if (result.done) {
resolve(result.value);
return;
}
// Convert to Promise and continue
Promise.resolve(result.value).then(
value => step(() => gen.next(value)),
error => step(() => gen.throw(error))
);
}
step(() => gen.next());
});
};
}
const fetchUserData = asyncToGenerator(fetchUserDataGenerator);
This is essentially what transpilers like Babel do when targeting older JavaScript versions.
How Generators Enable Pausing:
Generators have the ability to:
-
Yield control back to the caller with
yield -
Resume execution when
.next()is called - Maintain state between yields
This is the mechanism that allows async functions to "pause" at await points.
4. Level 3: JavaScript Runtime & Microtasks
When you await a Promise, here's what happens in the JavaScript event loop:
1. Hit await → Promise not resolved yet
2. Suspend function execution
3. Return control to event loop
4. When Promise resolves → Queue microtask
5. Microtask runs → Resume function execution
The Microtask Queue:
console.log('1: Synchronous');
async function example() {
console.log('2: Async function start');
await Promise.resolve();
console.log('4: After await');
}
example();
console.log('3: Synchronous end');
// Output:
// 1: Synchronous
// 2: Async function start
// 3: Synchronous end
// 4: After await
The "After await" runs in a microtask, which executes after the current synchronous code but before the next task (like setTimeout callbacks).
5. Level 4: V8's C++ Implementation
Now let's dive into V8, Chrome's JavaScript engine written in C++.
A. Async Function Objects
In V8, async functions are represented by JSAsyncFunctionObject. When you call an async function, V8:
// Simplified from v8/src/builtins/builtins-async-gen.cc
// When entering an async function
BUILTIN(AsyncFunctionEnter) {
// Create a new promise (the one the async function returns)
Handle<JSPromise> promise = factory->NewJSPromise();
// Create generator-like context to store state
Handle<Context> context = factory->NewAsyncFunctionContext();
// Store the promise for later resolution
context->set_extension(*promise);
return *promise;
}
B. Await Implementation
The await keyword is handled by the Await builtin:
// Simplified from v8/src/builtins/builtins-async-gen.cc
BUILTIN(AsyncFunctionAwait) {
// Get the awaited value
Handle<Object> value = args.at(1);
// Get the implicit promise (async function's return promise)
Handle<JSAsyncFunctionObject> async_function_object = args.at(0);
Handle<JSPromise> outer_promise = async_function_object->promise();
// Create a promise for the awaited value
Handle<JSPromise> promise = factory->NewJSPromiseWithoutHook();
// Resolve it with the awaited value
ResolvePromise(isolate, promise, value);
// Create PromiseReaction objects (the continuation)
Handle<JSFunction> on_fulfilled = CreateAsyncFunctionResumeFunction();
Handle<JSFunction> on_rejected = CreateAsyncFunctionRejectFunction();
// Attach reactions to the promise
PerformPromiseThen(isolate, promise, on_fulfilled, on_rejected, outer_promise);
// Suspend execution (return to event loop)
return ReadOnlyRoots(isolate).undefined_value();
}
C. Resuming After Await
When the promise resolves, V8 queues a microtask:
// Simplified from v8/src/objects/js-promise.cc
void EnqueueMicrotask(Isolate* isolate, Handle<Microtask> microtask) {
// Get the microtask queue
Handle<MicrotaskQueue> queue = isolate->default_microtask_queue();
// Add to the queue
queue->EnqueueMicrotask(*microtask);
// Notify that there are pending microtasks
isolate->SetHasPendingMicrotasks();
}
// When it's time to run microtasks
void RunMicrotasks(Isolate* isolate) {
HandleScope scope(isolate);
MicrotaskQueue* queue = isolate->default_microtask_queue();
while (queue->size() > 0) {
Microtask* microtask = queue->Dequeue();
microtask->Run(isolate);
}
}
D. Generator State Machine
V8 maintains a state machine for each async function:
enum class AsyncFunctionState {
kSuspendedStart, // Before first await
kSuspendedYield, // Suspended at await
kExecuting, // Currently running
kCompleted // Finished
};
// Stored in the function's context
struct AsyncFunctionData {
AsyncFunctionState state;
int resume_offset; // Where to resume in bytecode
Handle<Object> awaited_value; // Value to resume with
Handle<JSPromise> promise; // Return promise
};
E. Bytecode Level
V8's Ignition interpreter generates special bytecode for async functions:
// Simplified bytecode for: await somePromise()
SuspendGenerator // Save state and yield control
Return // Exit function temporarily
// --- Execution resumes here when promise resolves ---
ResumeGenerator // Restore state
GetGeneratorResumeValue // Get the resolved value
// Continue with next operations...
6. Putting It All Together
Let's trace through a complete example:
async function example() {
console.log('Before await');
const result = await Promise.resolve(42);
console.log('After await:', result);
return result * 2;
}
const promise = example();
console.log('Function called');
Step-by-Step Execution:
1. Call example()
- V8 creates
JSAsyncFunctionObject - Creates implicit return promise
- Starts synchronous execution
2. console.log('Before await')
- Executes synchronously
3. Hit await Promise.resolve(42)
- V8
AsyncFunctionAwaitbuiltin called - Creates promise reaction (continuation function)
- Suspends function (saves state in generator context)
- Returns to caller
4. console.log('Function called')
- Main execution continues
5. Promise Resolves
- V8 enqueues microtask with resolved value (42)
6. Microtask Queue Runs
- Calls continuation function
- Restores async function state
- Resumes execution at resume point
7. console.log('After await:', 42)
- Executes with resumed value
8. return 84
- Resolves implicit return promise with 84
Performance Implications
Understanding the implementation reveals why certain patterns are faster:
❌ Slower (unnecessary await):
async function slow() {
return await someAsyncOperation();
}
// Creates extra promise + microtask
✅ Faster (direct return):
async function fast() {
return someAsyncOperation();
}
// Returns promise directly
❌ Sequential (slower):
async function sequential() {
const a = await fetchA();
const b = await fetchB();
return [a, b];
}
✅ Parallel (faster):
async function parallel() {
const [a, b] = await Promise.all([fetchA(), fetchB()]);
return [a, b];
}
Key Takeaways
- Async/await is syntactic sugar over Promises and generator functions
- Generators provide the pause/resume mechanism through yield points
- The event loop and microtask queue handle scheduling
- V8 implements this in C++ with special objects, builtins, and bytecode
- Each await creates overhead - a promise wrapper and microtask
Understanding these layers helps you:
- Write more performant async code
- Debug async issues more effectively
- Make informed architectural decisions
- Appreciate the complexity hidden behind simple syntax
Top comments (0)