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.
🎯 What you'll learn
- What
async/awaitActually Is asyncFunctions Always Return a PromiseawaitPauses the Function, Not the ProgramawaitUnwraps 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.
// 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
async function example() {
return 42;
}
example(); // Promise { 42 } — NOT the number 42 directly
example().then(value => console.log(value)); // 42If the function body throws, the returned Promise rejects instead:
async function fails() {
throw new Error("boom");
}
fails().catch(e => console.log(e.message)); // "boom"3await Pauses the Function, Not the Program
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, 3await 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).
4await Unwraps a Promise's Value (or Throws Its Rejection)
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)
// 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:
// 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.
// 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
- Forgetting an
asyncfunction always returns a Promise — trying to use its return value directly (withoutawaitor.then()) gives you a Promise object, not the resolved value. - Using
awaitinside a non-asyncfunction — aSyntaxErrorat parse time; the enclosing function must be declaredasync. - Sequential
awaits that could run concurrently — awaiting one independent operation, then another, needlessly serializes work thatPromise.allcould run in parallel. - Forgetting to
try/catchanawait— an unhandled rejection from anawaited Promise crashes the async function (and, if nothing catches it further up, triggers anunhandledRejection). - Mixing
.then()andawaitinconsistently in the same function — not wrong, but usually a readability smell; pick one style per function.
📋 Quick Reference
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
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.
- 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.
- 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.
- 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.
- 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.
- 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.