- Published on
How async/await Actually Works
- Authors

- Name
- Alex Peng
- @aJinTonic
async/await made asynchronous JavaScript feel like synchronous code, which is both a gift and a footgun. When something goes wrong, unhandled rejections, unexpected ordering, performance problems from sequential awaits, you need to understand what's actually happening.
The Event Loop First
JavaScript is single-threaded. It can only run one thing at a time. The event loop is what makes it feel concurrent.
The runtime has:
- A call stack: the currently executing code
- A task queue (macrotask queue): callbacks from setTimeout, setInterval, I/O
- A microtask queue: Promise callbacks, queueMicrotask
The event loop cycle:
- Run everything on the call stack until it's empty
- Drain the entire microtask queue
- Take one task from the task queue and run it
- Repeat
Microtasks always run before the next macrotask. This is why Promise .then() callbacks run before setTimeout callbacks, even with setTimeout(..., 0):
setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))
console.log('sync')
// Output:
// sync
// promise
// timeout
Promises Are State Machines
A Promise is an object with three states: pending, fulfilled, or rejected. Once it transitions out of pending, it never changes state again.
const p = new Promise((resolve, reject) => {
// executor runs synchronously
setTimeout(() => resolve(42), 1000)
})
p.then(value => console.log(value)) // schedules a microtask when p fulfills
When resolve(42) is called, the Promise transitions to fulfilled and schedules all registered .then() callbacks as microtasks. Those callbacks run after the current synchronous code finishes.
async/await Is Compiled Away
async/await is syntactic sugar. The JavaScript engine transforms it into a generator-based state machine. You can think of await as "pause this function, yield control back to the caller, and resume when the awaited Promise settles."
This function:
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
return user
}
Is roughly equivalent to:
function fetchUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => user)
}
The key insight: await doesn't block the thread. It suspends the async function and lets other code run. The function resumes as a microtask when the awaited Promise settles.
The Sequential vs. Parallel Trap
The most common performance mistake with async/await is accidentally making parallel operations sequential:
// SLOW: these run one after the other
async function loadDashboard(userId) {
const user = await getUser(userId) // waits
const orders = await getOrders(userId) // then waits
const stats = await getStats(userId) // then waits
return { user, orders, stats }
}
The three fetches are independent, there's no reason to wait for getUser before starting getOrders. The total time is the sum of all three.
// FAST: all three run concurrently
async function loadDashboard(userId) {
const [user, orders, stats] = await Promise.all([
getUser(userId),
getOrders(userId),
getStats(userId),
])
return { user, orders, stats }
}
Promise.all fires all three Promises immediately and resolves when all of them settle. Total time is the slowest of the three, not the sum.
When you have a dependency between operations (you need the user before you can fetch orders), sequential awaits are correct. When you don't, use Promise.all.
Error Handling
Unhandled Promise rejections are a common source of silent failures. A few patterns to know:
// try/catch catches rejected awaits
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.json()
} catch (err) {
// handles both network errors and HTTP errors
console.error('Failed to fetch user:', err)
throw err // re-throw if callers need to handle it
}
}
// Promise.all rejects if ANY promise rejects
try {
const results = await Promise.all([fetch('/a'), fetch('/b')])
} catch (err) {
// one failed, you don't know which, and the other result is lost
}
// Promise.allSettled gives you all results regardless of failures
const results = await Promise.allSettled([fetch('/a'), fetch('/b')])
results.forEach(result => {
if (result.status === 'fulfilled') console.log(result.value)
else console.error(result.reason)
})
Use Promise.allSettled when you want all results and can handle individual failures, Promise.all when you need all to succeed or want to fail fast.
The Mental Model
When you see await, read it as: "this function suspends here, hands control back to the event loop, and a microtask will resume it when the Promise settles." Nothing blocks. Other code can run in the meantime. The async function continues from where it left off when it's scheduled again.
Once you have that mental model, async code in JavaScript stops feeling like magic and starts feeling like a well-defined queueing system.