Published on

How async/await Actually Works

Authors

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:

  1. Run everything on the call stack until it's empty
  2. Drain the entire microtask queue
  3. Take one task from the task queue and run it
  4. 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.