How is async/await different from promises (and how does it work internally)?
async/await is syntax sugar over promises and generators. `await` pauses the function, yields to the microtask queue, and resumes when the promise settles. Same semantics, dramatically better readability and error handling.
async/await is syntactic sugar over promises. It doesn't replace them or add a new asynchrony primitive — it gives you a more readable way to write code that consumes promises sequentially, and it makes try/catch work the way developers intuitively expect. Internally, an async function compiles to a state machine over a .then chain. Understanding the relationship (what await is sugar for) is the staff-level signal.
The contract:
- An
asyncfunction always returns a Promise. Even if its body isasync function foo() { return 1; }, the return value isPromise.resolve(1). Throwing inside an async function returns a rejected promise. awaitunwraps a promise. When the engine hitsawait promise, it:
- If
promiseis not a thenable, wraps it inPromise.resolve(value). - Registers a
.thencontinuation pointing back to the rest of the async function. - Returns control to the caller (the async function's invocation immediately returns a pending promise).
- When the awaited promise settles, the continuation is scheduled as a microtask. Code after
awaitruns in that microtask — never synchronously, even when the promise is already resolved.
- Errors propagate via promise rejection, which is translated to a thrown exception inside the async body. So
try { await x } catch (e) { ... }actually works, where.then(...).catch(...)would have required a separate handler.
Equivalent forms:
async function fetchUser(id: string) {
const res = await fetch(\`/api/users/\${id}\`);
if (!res.ok) throw new Error('not found');
const user = await res.json();
return user;
}
// Equivalent promise chain:
function fetchUser(id: string) {
return fetch(\`/api/users/\${id}\`)
.then(res => { if (!res.ok) throw new Error('not found'); return res.json(); })
.then(user => user);
}Same semantics — same microtask scheduling, same error propagation. The differences are entirely ergonomic: async/await reads top-down, debugger step-over works naturally, locals stay in scope, conditional flow doesn't fragment across thens.
How it actually compiles (the staff-level part). TC39 specifies an async function as a generator-like state machine. Roughly:
// async function foo() { const a = await p; return a + 1; }
function foo() {
return new Promise((resolve, reject) => {
let state = 0;
let a;
function step(value) {
try {
switch (state) {
case 0:
state = 1;
return p.then(step, reject);
case 1:
a = value;
resolve(a + 1);
}
} catch (e) { reject(e); }
}
step();
});
}Each await is a yield point; the runtime captures the local frame, registers a .then continuation, and resumes via a microtask. Babel/SWC's lowered output looks just like this for older targets.
Why await always defers to the microtask queue, even for resolved promises. Specified behavior: await Promise.resolve(1) causes the rest of the function to run one microtask later. Code outside the function sees the promise as pending until the next microtask drain. This is occasionally surprising but ensures consistent ordering across resolved/unresolved cases.
When raw promises still beat async/await:
- Parallelism. This is the most common bug. Sequential awaits run serially:
const a = await getA(); const b = await getB();waits 2x. If they're independent, usePromise.all([getA(), getB()])for parallel — both kick off before either awaits. - Fire-and-forget. If you don't need the result, don't await; just call the function.
asyncmakes implicit not-awaiting visually quiet — a linter rule (no-floating-promises) catches accidental forgets. - Composition.
Promise.race,Promise.any,Promise.allSettledare clearer than loops with awaits for fan-out + reduce patterns. - Mid-chain transforms.
.then(parse).then(validate).then(store)reads as a pipeline; awaits with locals read as imperative. - Top-level outside modules. Top-level
awaitexists but only in ES modules; in script contexts you need an IIFE.
Common bugs:
- Forgotten
await. Returns a Promise where a value was expected.if (await isAdmin(user))vsif (isAdmin(user))— the second is always truthy because Promises are objects. - Serial awaits when parallel was intended.
for (const id of ids) { await fetchOne(id); }is sequential. EitherPromise.all(ids.map(fetchOne)), or usefor awaitonly when you mean serial. - Try/catch swallowing. Forgetting to re-throw or to translate the caught error into a meaningful response.
- Unhandled rejection when you fire async work without awaiting and without catching — modern Node logs and may eventually exit.
for await ... of is the async-iteration form, useful for async generators and streaming responses (for await (const chunk of response.body)).
Interview summary. "async/await is sugar over .then/.catch. An async function returns a Promise; await unwraps a Promise and yields to the microtask queue. Internals are a generator state machine. The big gotcha is sequential awaits on independent work — use Promise.all for parallelism."
Code
Follow-up questions
- •What does an async function return if you don't return anything explicitly?
- •Show how await schedules a microtask — what's the output of a mixed setTimeout + await example?
- •Why does `await` inside a forEach not behave like you expect?
Common mistakes
- •Sequentially awaiting independent promises and tanking latency.
- •Putting `await` inside `forEach` — forEach ignores the returned promise; sequence is lost.
- •Forgetting `await` and getting a `Promise<X>` instead of `X`.
Performance considerations
- •Each `await` schedules a microtask — millions of awaits in a hot loop have measurable overhead.
Edge cases
- •`await` on a non-promise value still yields one microtask (it's wrapped in `Promise.resolve`).
- •A throw inside an async function rejects the returned promise; outside callers must `.catch` or `await` to handle it.
Real-world examples
- •ORMs and SDKs prefer async/await in their docs because the call sites read like synchronous code while remaining non-blocking.