Skip to content

async/await Under the Hood

intermediate17 min read

The Async Function That Blocked the Event Loop

A developer writes this, fully expecting it to be "non-blocking" because they used async/await:

async function processItems(items) {
  for (const item of items) {
    await processItem(item); // each item takes 50ms of CPU work
  }
}

With 100 items, this takes 5 seconds. During those 5 seconds, the page is jank-free because... wait, no. The page freezes. Yep. await doesn't make CPU-bound work non-blocking. It only yields at await points, and processItem's 50ms of synchronous CPU work blocks the event loop just like it would without async/await.

Understanding what async/await actually does -- not what it looks like it does -- prevents this entire class of bugs.

The Mental Model

Mental Model

An async function is a function that can pause and resume. Think of it as reading a book with bookmarks. When you hit an await, you place a bookmark (save your position), close the book (pop off the call stack), and go do other things (other tasks run). When the awaited value is ready, you open the book back to your bookmark (resume the function via a microtask) and keep reading. But between the bookmarks, you're reading straight through — no pausing, no yielding.

The Desugaring

Here's what's really going on. async/await is syntactic sugar over promises. Here's how the engine transforms it:

// What you write:
async function fetchData() {
  console.log('start');
  const response = await fetch('/api/data');
  const data = await response.json();
  console.log('done');
  return data;
}

// Conceptually equivalent to:
function fetchData() {
  return new Promise((resolve, reject) => {
    console.log('start');
    fetch('/api/data')
      .then(response => {
        return response.json();
      })
      .then(data => {
        console.log('done');
        resolve(data);
      })
      .catch(reject);
  });
}

Each await becomes a .then() that receives the resolved value and continues execution. The function's local variables are preserved across await points through a closure (or, in the engine, a generator-like state machine).

The Generator Connection

Before async/await was standardized, developers used generators with a runner function to achieve the same effect. This is actually close to how engines implement it internally:

// The generator approach (historical):
function* fetchDataGen() {
  console.log('start');
  const response = yield fetch('/api/data');
  const data = yield response.json();
  console.log('done');
  return data;
}

// Runner that drives the generator:
function run(generatorFn) {
  const gen = generatorFn();

  function step(value) {
    const result = gen.next(value);
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(step);
  }

  return step();
}

run(fetchDataGen).then(data => console.log(data));

yield pauses the generator. The runner takes the yielded promise, waits for it, then resumes the generator with the resolved value. async/await does exactly this, built into the language.

Quiz
What does an async function always return?

What Happens at Each await

Let's get precise about what the engine does when it hits await expression:

  1. Evaluate the expression — if it's not a Promise, wrap it in Promise.resolve()
  2. Suspend the function — pop its frame off the call stack, save its state
  3. Schedule a microtask — when the awaited promise settles, queue a microtask to resume
  4. Return to the caller — the caller receives the async function's outer promise (still pending)
  5. When the microtask runs — restore the function's state, push it back on the call stack, continue from after the await

Step through this to see exactly how await suspends and resumes:

The output is C, A, D, B. See the pattern? Everything before await is synchronous. Everything after await is a microtask. Once you internalize this, async/await ordering puzzles stop being puzzles.

Common Trap

await does NOT make things run on another thread. The synchronous code before each await runs on the main thread and blocks it completely. If processItem() takes 50ms of CPU time, await processItem() blocks for 50ms — the await only helps if processItem returns a promise that actually defers work (to I/O, a worker, etc.).

await on Non-Promise Values

Here's a fun one -- you can await anything. Non-Promise values are wrapped in Promise.resolve():

async function f() {
  const x = await 42;      // Promise.resolve(42)
  const y = await 'hello';  // Promise.resolve('hello')
  const z = await null;     // Promise.resolve(null)
  console.log(x, y, z);     // 42 'hello' null
}

This means await 42 is not synchronous. It still suspends the function and resumes via a microtask. This is occasionally useful for intentionally yielding within an async function.

Quiz
What is the output of: async function f() { return 1; } console.log(f());

Error Handling with async/await

try/catch replaces .catch()

// Promise version
fetchData()
  .then(process)
  .catch(handleError);

// async/await version
async function run() {
  try {
    const data = await fetchData();
    await process(data);
  } catch (err) {
    handleError(err);
  }
}

try/catch in async functions catches both synchronous errors and rejected promises:

async function riskyOperation() {
  try {
    JSON.parse('{invalid}');      // synchronous throw — caught
    await failingFetch();          // rejection — also caught
    throwingFunction();           // synchronous throw — also caught
  } catch (err) {
    // Handles all three types
  }
}

The Unhandled Rejection Trap

If an async function throws and nobody awaits it or attaches .catch():

async function oops() {
  throw new Error('silent death');
}

oops(); // No await, no .catch() — unhandled rejection!

This triggers the unhandledrejection event in browsers and crashes the process in Node.js.

Production Scenario: Sequential vs. Parallel await

This is probably the most common async/await performance mistake in the wild:

// SEQUENTIAL: 3 seconds total (1 + 1 + 1)
async function loadDashboard() {
  const users = await fetchUsers();     // waits 1s
  const orders = await fetchOrders();   // then waits 1s
  const metrics = await fetchMetrics(); // then waits 1s
  return { users, orders, metrics };
}

// PARALLEL: 1 second total (all at once)
async function loadDashboard() {
  const [users, orders, metrics] = await Promise.all([
    fetchUsers(),    // starts immediately
    fetchOrders(),   // starts immediately
    fetchMetrics(),  // starts immediately
  ]);
  return { users, orders, metrics };
}

The sequential version awaits each fetch before starting the next. The parallel version starts all fetches simultaneously and awaits them together. If the requests are independent, the parallel version is always correct.

The Subtle Bug: Accidental Sequential

// This looks parallel but is SEQUENTIAL:
async function loadData() {
  const userPromise = fetchUsers();
  const orderPromise = fetchOrders();

  const users = await userPromise;   // OK so far
  const orders = await orderPromise; // This IS parallel... but only by accident
}

// The bug appears when you add error handling:
async function loadData() {
  try {
    const users = await fetchUsers();   // sequential — blocks here
    const orders = await fetchOrders(); // doesn't start until users finishes
  } catch (err) {
    // ...
  }
}

If you want parallel execution, always use Promise.all or Promise.allSettled. Don't rely on starting promises before awaiting them — it's fragile and error-handling doesn't compose well.

Quiz
What happens if one of the promises in Promise.all rejects, but you're using try/catch around the await?

await in Loops

The way you use await in loops makes all the difference. Get it wrong and you're either needlessly slow or silently broken.

for...of with await — Sequential

async function processSequential(urls) {
  const results = [];
  for (const url of urls) {
    const res = await fetch(url); // one at a time
    results.push(await res.json());
  }
  return results;
}

Each iteration waits for the previous to complete. Use this when order matters or when you need to throttle requests.

Promise.all with map — Parallel

async function processParallel(urls) {
  const results = await Promise.all(
    urls.map(async url => {
      const res = await fetch(url);
      return res.json();
    })
  );
  return results;
}

All requests fire simultaneously. Use this when requests are independent.

forEach with await — BROKEN

// BROKEN — forEach doesn't await async callbacks
async function processBroken(urls) {
  urls.forEach(async url => {
    const res = await fetch(url);    // fires and forgets
    console.log(await res.json());   // this runs eventually, but...
  });
  console.log('done'); // this runs BEFORE any fetch completes!
}

forEach ignores the return value of its callback. Since async functions return promises, forEach gets back promises but doesn't await them. All fetches run concurrently with no error handling and "done" logs before any complete.

Common Mistakes

What developers doWhat they should do
Thinking await makes CPU-bound code non-blocking
await pauses the function at the await point. Everything before the await runs synchronously. A 50ms synchronous computation blocks for 50ms regardless of await.
await only helps if the awaited expression returns a promise that defers to I/O or a worker. CPU work between awaits blocks the main thread.
Using sequential await for independent operations
Three 1-second requests take 3 seconds sequentially but only 1 second in parallel. This is one of the most common performance mistakes in async code.
Use Promise.all([a(), b(), c()]) for independent parallel operations. Sequential await serializes them unnecessarily.
Using forEach with async callbacks
forEach doesn't await async callbacks. The iterations fire concurrently with no ordering guarantee and no error handling. It's essentially fire-and-forget.
Use for...of for sequential, or Promise.all(arr.map(async ...)) for parallel.
Wrapping already-async code in new Promise()
new Promise((resolve) => { fetch(url).then(resolve) }) is the 'promise constructor antipattern.' Just return fetch(url) or use async/await directly.
If you already have a promise, just return it. async functions handle promises natively.

Challenge: Trace the Async Execution

Challenge: Async Ordering

async function first() {
  console.log('1');
  await second();
  console.log('2');
}

async function second() {
  console.log('3');
  await Promise.resolve();
  console.log('4');
}

console.log('5');
first();
console.log('6');
Show Answer

Output: 5, 1, 3, 6, 4, 2

Trace:

  1. console.log('5') — synchronous. Output: 5
  2. first() is called. console.log('1') — synchronous inside first(). Output: 5, 1
  3. await second() — calls second(). console.log('3') — synchronous inside second(). Output: 5, 1, 3
  4. await Promise.resolve() inside second() — suspends second(). Queues microtask to resume.
  5. second() is suspended, so await second() in first() doesn't continue yet (it's waiting for second's promise). Control returns to the top-level script.
  6. console.log('6') — synchronous. Output: 5, 1, 3, 6
  7. Script task ends. Microtask checkpoint: resume second() after its await. console.log('4'). Output: 5, 1, 3, 6, 4
  8. second() returns. Its promise fulfills. This queues a microtask to resume first() after await second().
  9. Resume first(). console.log('2'). Output: 5, 1, 3, 6, 4, 2

Key insight: both async functions run synchronously until their first await. The await suspends the function and returns control to the caller. After the script ends, microtasks resume the functions in order.

Key Rules

Key Rules
  1. 1async functions ALWAYS return a Promise. Even 'return 42' becomes Promise.resolve(42). Even 'throw err' becomes Promise.reject(err).
  2. 2Code before the first await is SYNCHRONOUS. It runs immediately when the function is called, on the current call stack.
  3. 3Each await suspends the function and schedules a microtask to resume it. Between awaits, other tasks and microtasks can run.
  4. 4await does NOT make CPU-bound work non-blocking. It only yields at the await point. Use Web Workers or chunking for CPU-heavy operations.
  5. 5Use Promise.all for parallel operations, for...of with await for sequential, never forEach with async callbacks.