How async/await Works Under the Hood
async/await made asynchronous code look synchronous, and that was a genuine improvement. But the improvement is cosmetic. The underlying mechanics didn’t change - a single-threaded event loop is still running, Promises are still the primitive, and all the old failure modes still apply. They’re just easier to miss now because the code looks like it blocks.
Understanding what’s actually happening is useful for a specific reason: the bugs that come from not understanding it are subtle. Forgetting await, accidentally running things sequentially that could run in parallel, swallowing errors in async functions - these are the mistakes that survive code review because the code looks correct.
The foundation: the event loop
JavaScript (and Node.js) is single-threaded. There is one call stack. One thing runs at a time.
Asynchronous operations - network requests, file reads, timers - are handled outside the JavaScript thread by the environment (the browser’s Web APIs or Node’s libuv). When the operation completes, a callback is placed in a queue. The event loop’s job is to move callbacks from that queue onto the call stack when the stack is empty.
This is the entire model. async/await is syntactic sugar that operates within it - it doesn’t add threads, it doesn’t change the execution model.
Promises: the actual primitive
A Promise is an object representing a value that will be available in the future. It’s in one of three states: pending, fulfilled, or rejected.
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 1000);
});
p.then(value => console.log(value)); // logs 42 after 1 second
.then() registers a callback that runs when the Promise resolves. The callback is not called immediately - it’s placed in the microtask queue when the Promise settles, and the event loop picks it up after the current task finishes.
The microtask queue has higher priority than the regular task queue (where setTimeout callbacks land). After each task, all microtasks are drained before the next task runs.
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('sync');
// Output:
// sync
// microtask
// timeout
What async/await compiles to
An async function always returns a Promise. The await keyword suspends execution of the function - not the thread - until the awaited Promise settles. The rest of the function is implicitly registered as a .then() callback.
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
return user;
}
This is mechanically equivalent to:
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => user);
}
await is a checkpoint. When the engine reaches it, it saves the function’s state (local variables, position in the code) and returns control to the event loop. When the awaited Promise resolves, the function resumes from where it left off. The saved state is the function’s continuation.
The implementation varies by engine - V8 uses generators internally - but the mental model is: await is .then(), and everything after await is the callback.
Sequential vs parallel: the most common mistake
Because await looks like synchronous code, it’s easy to accidentally serialize operations that could run concurrently.
// Sequential: each waits for the previous to finish
async function loadDashboard(userId) {
const user = await fetchUser(userId); // waits
const orders = await fetchOrders(userId); // then waits
const billing = await fetchBilling(userId); // then waits
return { user, orders, billing };
}
Three independent API calls, each waiting for the previous. Total time: sum of all three latencies.
// Parallel: all start immediately, wait for all to finish
async function loadDashboard(userId) {
const [user, orders, billing] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchBilling(userId),
]);
return { user, orders, billing };
}
Promise.all takes an array of Promises, starts all of them, and waits until every one has settled. Total time: the slowest of the three. For independent operations, this is almost always what you want.
Promise.all rejects immediately if any Promise rejects. If you want all results regardless of individual failures, use Promise.allSettled.
Error handling
In a .then() chain, you add .catch() at the end. In async/await, you use try/catch:
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (err) {
console.error('Failed to load user:', err);
throw err; // re-throw so the caller knows it failed
}
}
One failure mode worth knowing: an async function that throws synchronously before any await still returns a rejected Promise - it does not throw synchronously to the caller. All async errors are Promise rejections.
async function broken() {
throw new Error('immediately');
// This is equivalent to: return Promise.reject(new Error('immediately'))
}
// This won't throw - it returns a rejected Promise silently
broken();
// This catches it
broken().catch(err => console.error(err));
// So does this
try {
await broken();
} catch (err) {
console.error(err);
}
Unhandled Promise rejections in Node.js will crash the process (since Node 15). In browsers they generate a warning. Always handle rejections.
async in loops
forEach does not work with async functions the way you might expect:
// This does NOT wait for each fetch - forEach ignores returned Promises
items.forEach(async (item) => {
await processItem(item);
});
console.log('done'); // logs before any item is processed
Use a for...of loop if you need sequential processing:
for (const item of items) {
await processItem(item); // sequential
}
Or Promise.all with .map for parallel:
await Promise.all(items.map(item => processItem(item))); // parallel
What await doesn’t do
await does not block the thread. When you write await fetch(...), the JavaScript thread is free to process other events. The function is suspended, but the event loop continues running. This is the correct mental model: await yields, it does not block.
await also cannot be used outside an async function (in most contexts). Top-level await is available in ES modules and works as you’d expect, but in CommonJS or regular function context, you need to be inside an async function.
The syntax hides the asynchrony but not its consequences. Sequential awaits are sequential. Errors still need handling. Operations still run on the same thread. The improvement async/await delivered is readability and composability - the underlying execution model is unchanged from the callback era.