Engineering Notes
Home/Languages/JavaScript/async / await
Languages · JavaScript

async / await

async/await is syntax sugar over Promises — it doesn't replace them, it makes code that uses them read like synchronous code, while still being fully asynchronous underneath.

💻 Languages● Advanced⏱ 5 min read🛠️ 4 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • What async/await Actually Is
  • async Functions Always Return a Promise
  • await Pauses the Function, Not the Program
  • await Unwraps a Promise's Value (or Throws Its Rejection)
  • Sequential vs Concurrent await (Preview)

1What async/await Actually Is

async/await is syntax sugar over Promises — it doesn't replace them, it makes code that uses them read like synchronous code, while still being fully asynchronous underneath.

js
// Promise chain
function loadUser(id) {
  return fetch(`/users/${id}`).then(res => res.json());
}

// Equivalent async/await
async function loadUser(id) {
  const res = await fetch(`/users/${id}`);
  return res.json();
}

2async Functions Always Return a Promise

js
async function example() {
  return 42;
}
example(); // Promise { 42 } — NOT the number 42 directly
example().then(value => console.log(value)); // 42

If the function body throws, the returned Promise rejects instead:

js
async function fails() {
  throw new Error("boom");
}
fails().catch(e => console.log(e.message)); // "boom"

3await Pauses the Function, Not the Program

js
async function example() {
  console.log("1: before await");
  const result = await delay(100, "done"); // pauses HERE, but only this function
  console.log("3: after await:", result);
}
example();
console.log("2: runs immediately — the rest of the program is NOT blocked");
// order: 1, 2, 3

await can only be used inside an async function (or at the top level of an ES module). Everything after await is scheduled as a microtask, exactly like a .then() callback (see the Microtasks vs Macrotasks topic).

The function runs up to await, suspends while the rest of the program continues, then resumes as a microtask once the promise settles
The function runs up to await, suspends while the rest of the program continues, then resumes as a microtask once the promise settles

4await Unwraps a Promise's Value (or Throws Its Rejection)

js
async function example() {
  const value = await Promise.resolve(42);   // value = 42, unwrapped automatically
  try {
    await Promise.reject(new Error("failed"));
  } catch (e) {
    console.log(e.message); // "failed" — a rejected awaited Promise throws, catchable with try/catch
  }
}

await on a rejected Promise behaves exactly like a throw — this is what makes try/catch work naturally with async code (full detail in the Async Error Handling topic).

5Sequential vs Concurrent await (Preview)

js
// SEQUENTIAL — each await waits for the previous one to finish first
async function sequential() {
  const a = await delay(100, "a"); // waits 100ms
  const b = await delay(100, "b"); // THEN waits another 100ms — total ~200ms
  return [a, b];
}

// CONCURRENT — both start immediately, only wait once
async function concurrent() {
  const [a, b] = await Promise.all([delay(100, "a"), delay(100, "b")]); // total ~100ms
  return [a, b];
}

Full depth on this distinction (and when to use each) is covered in the Parallel vs Sequential Async Operations topic — but it's the single most impactful performance decision when writing async/await code.

6Top-Level await

In ES modules, await can be used directly at the top level, outside any async function:

js
// inside an ES module (.mjs, or "type": "module")
const data = await fetch("/config.json").then(r => r.json());
console.log(data);
Under the HoodFrom Generator-Based Sugar to a Native Suspend Mechanism▾

How async/await was originally understood — and transpiled

Before engines implemented async/await natively, and still today when compiling for older targets (Babel's "regenerator" transform), an async function was modeled as a generator function plus an automatic driver: the compiler rewrote every await expr into a yield expr, then wrapped the whole thing in a helper that repeatedly calls .next() on the resulting generator, feeding each yielded Promise's resolved value back in via .next(value) — automating exactly the manual "drive a generator with Promises" pattern by hand, using the exact generator suspend/resume mechanics from the Iterators and Generators topic.

js
// Roughly what async function example() { const x = await foo(); return x + 1; }
// used to desugar to, conceptually:
function example() {
  return driveWithPromises(function* () {
    const x = yield foo();
    return x + 1;
  });
}

Why modern engines no longer implement it this way, literally

This generator-based model works but carries real overhead: extra generator-object allocation, extra microtask ticks per await just to drive the machinery. V8's engineering team specifically re-implemented async functions around 2018-2019 to use a more direct native suspend/resume mechanism — conceptually similar to a generator's heap-allocated suspended frame (Iterators and Generators topic), but purpose-built for async/await rather than routed through the full generator protocol. Part of that work measurably reduced the number of microtask "ticks" needed to resume after each await (from multiple ticks down to one) — a genuine, documented performance improvement, not just an implementation detail.

The practical takeaway

The "async is sugar over generators + Promises" mental model remains the right way to reason about what await does (suspend here, resume when the awaited Promise settles, propagate rejection as a throw) — but it's worth knowing that on modern engines it is no longer literally true as an implementation fact. This is a good example of the general pattern throughout this course: the observable behavior (what section 1-6 describe) is what the spec guarantees and what you should design around; the literal mechanism underneath is free to evolve, and has.

⚠️ Common Pitfalls

  1. Forgetting an async function always returns a Promise — trying to use its return value directly (without await or .then()) gives you a Promise object, not the resolved value.
  2. Using await inside a non-async function — a SyntaxError at parse time; the enclosing function must be declared async.
  3. Sequential awaits that could run concurrently — awaiting one independent operation, then another, needlessly serializes work that Promise.all could run in parallel.
  4. Forgetting to try/catch an await — an unhandled rejection from an awaited Promise crashes the async function (and, if nothing catches it further up, triggers an unhandledRejection).
  5. Mixing .then() and await inconsistently in the same function — not wrong, but usually a readability smell; pick one style per function.

📋 Quick Reference

text
async function f() { ... }        → always returns a Promise
await promise                       → unwraps value, or throws on rejection
await only inside async functions    → (or top-level in ES modules)
try { await x } catch (e) {}         → standard error handling pattern

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What does async/await do?core▾

async functions always return a promise; await pauses the function until a promise settles, resuming with its value or throwing its rejection. It makes asynchronous code read sequentially with normal try/catch.

Follow-ups interviewers ask
  • Does await block the thread? No, only the async function; other code keeps running.
Q2What's the most common performance mistake with await?core▾

Awaiting independent operations sequentially. Start them together and await Promise.all([a(), b()]) to run concurrently.

Follow-ups interviewers ask
  • When sequential is right? When each step depends on the previous result or you need rate limiting.
Q3How does await work inside loops?core▾

for…of with await runs iterations sequentially. forEach with an async callback doesn't wait at all (fire-and-forget). Use Promise.all(items.map(async …)) for concurrency, with a limiter for large lists.

Follow-ups interviewers ask
  • Limiter libraries? p-limit, p-map.
Q4How do you cancel async operations?senior▾

Promises can't be cancelled themselves; pass an AbortSignal to cancellable APIs (fetch, many libraries), check signal.aborted in your own loops, and reject on abort.

Follow-ups interviewers ask
  • Timeout helper? AbortSignal.timeout(5000).
Q5What does returning vs awaiting inside try/catch change?core▾

return await promise inside try/catch lets the catch handle a rejection; return promise without await skips the local catch because the function already returned.

Follow-ups interviewers ask
  • Lint rule? no-return-await is outdated; return-await in try blocks is correct.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.