Q: What are common pitfalls when using async/await? How does it actually work under the hood?

Answer:

async/await is syntactic sugar over promises. An async function always returns a Promise; an await expression pauses the function until the awaited promise settles, then either resumes with the resolved value or throws the rejection reason. The function is conceptually transformed into a state machine using generators and the microtask queue.

Mental Model

async function foo() {
  const a = await stepA();
  const b = await stepB(a);
  return b;
}

// Roughly equivalent to:
function foo() {
  return stepA().then(a => stepB(a)).then(b => b);
}

Every await is a then boundary — a microtask hop. There is no thread; the function "pauses" by returning control to the event loop.

Pitfall 1: Sequential vs Parallel

// BAD: 6 seconds total
const user    = await fetchUser();      // 2s
const orders  = await fetchOrders();    // 2s  (independent of user!)
const reviews = await fetchReviews();   // 2s

// GOOD: 2 seconds total
const [user, orders, reviews] = await Promise.all([
  fetchUser(), fetchOrders(), fetchReviews(),
]);

[!NOTE] Only chain awaits sequentially when each call genuinely depends on the previous result.

Pitfall 2: await Inside forEach

// Does NOT wait! forEach ignores returned promises.
items.forEach(async item => {
  await process(item);
});
console.log('done'); // logs before any item finishes

// Use for...of for sequential
for (const item of items) {
  await process(item);
}

// Or Promise.all for parallel
await Promise.all(items.map(process));

Pitfall 3: Forgetting to Await Means Unhandled Rejection

async function save() {
  doNetworkCall();           // fire-and-forget; rejection becomes UnhandledPromiseRejection
  return 'ok';
}

If doNetworkCall() rejects, the error is lost. Either await it or attach .catch(handleError).

Pitfall 4: try/catch Only Catches Synchronous Awaited Errors

async function load() {
  try {
    return fetchData();      // NO await: error escapes try/catch
  } catch (e) { /* unreachable */ }
}

The return here returns a promise; the function exits before the promise rejects, so the catch never sees it. Add await (return await fetchData()) or .catch the call.

Pitfall 5: Mixing await and Loops in Hot Paths

Awaiting inside a tight loop serializes work. For independent tasks, build a promise array and await Promise.all([...]). For backpressure, batch with chunks:

for (const chunk of chunks(items, 10)) {
  await Promise.all(chunk.map(process));
}

Pitfall 6: Top-Level Await Blocks Module Graph

ES module top-level await is allowed but delays evaluation of every importing module. Avoid long awaits at module top-level.

Under the Hood — State Machine

   async function f() {
     const x = await p1;   --> suspend, register .then on p1
     const y = await p2;   --> on resume with x, suspend on p2
     return x + y;         --> on resume with y, resolve f's promise
   }

Each await resumption is queued as a microtask. That means an await always yields at least once to the event loop, even if the value is already resolved.

Pitfall 7: await on a Non-Promise

await 42 is legal — the value is wrapped in Promise.resolve(42) and resumes on the next microtask tick. Useful in tests but introduces unnecessary microtask hops in hot code.