async/await
Prerequisites assumed
This chapter assumes you are already fluent with Promise semantics from the previous chapter: chaining, error propagation, rejection, Promise.all/allSettled/race/any, and that you know the event loop model — synchronous run-to-completion, microtask queue, macrotask queue. We treat async/await not as sugar to be ignored but as a control-flow mechanism with observable, sometimes surprising, semantics. Where behavior differs from raw promise chaining, we surface the difference.
The core model
An async function is a function that always returns a promise. Inside, await suspends that function, queues the rest of it as a continuation on the promise being awaited (a microtask), and returns control to the caller. Crucially, the caller of an async function keeps running the moment the promise is returned — the suspension is local.
async function main() {
const value = await Promise.resolve(42);
console.log(value); // 42
}
main();
console.log('main() has returned a promise; program continues');main() is invoked synchronously; everything up to the first await runs eagerly. Only after hitting await does the function yield. This is why the first segment of an async function is sometimes called the synchronous prefix — errors thrown before the first await become a synchronous throw, not a rejection:
async function bad() {
throw new Error('sync prefix');
}
bad(); // returns a rejected promise — the throw is convertedNote the subtlety: even the "synchronous throw" above is captured by the async machinery into a rejection, because an async function cannot throw synchronously. But compare:
function throwsSync() {
throw new Error('now');
}
const p = Promise.resolve().then(() => throwsSync()); // rejection
throwsSync(); // synchronous throw — crashes the call stackAwait semantics in detail
await expr desugars roughly to: take the value, if it is not a thenable wrap it in Promise.resolve, register a then continuation whose body is the remainder of the function, then return. await on a non-promise value still yields to the microtask queue — an await of a plain number is one microtask tick, not zero.
That last point is a real behavioral difference from promise chains. In a .then chain there is no forced pause between steps; each await inserts a microtask boundary. In a tight loop this matters:
async function spin() {
for (let i = 0; i < 1_000_000; i++) {
await undefined; // 1M microtask ticks — measurable overhead
}
}V8 and other engines optimize heavily here (await is specialized in the bytecode), but if the awaited value is already resolved you are still paying queue hops. Batch what you can.
Execution order: the interleaving hazard
Sequential awaits inside one function are easy. The hazards come from concurrent async functions interleaving their await points. Consider shared mutable state:
let balance = 100;
async function withdraw(amount) {
if (balance >= amount) { // check
await dbLog(amount); // suspension point
balance -= amount; // act
}
}
await Promise.all([withdraw(80), withdraw(80)]);
// balance can end at 20: both passed the check before either actedThis is a check-then-act race. There is no lock primitive; you either avoid the interleaving (single-threaded ordering), restructure with an async queue/mutex, or perform the mutation atomically before any await.
Error handling
try/catch around await catches rejections from that await, including errors thrown by code after it in the same block. Design decisions to make deliberately:
- Per-await catch vs. block catch. A block-wide
tryconflates which operation failed.await promise.catch(handler)scopes recovery to one call. - Errors crossing await boundaries lose no stack frames if the async context chains them, but the stack of who awaited only exists while suspended. Use
async-stack-traces-style runtimes (all modern engines) and be aware stack traces still truncate at the boundary of fire-and-forget calls. - Fire-and-forget rejects silently. An async function invoked without
awaitproduces a promise nobody handles → unhandled rejection. In Node this crashes the process by default since v15; in browsers it logs and can be hooked viaunhandledrejection.
async function risky() {
try {
const a = await stepA(); // failure here...
const b = await stepB(a); // ...and here, same catch
return b;
} catch (err) {
// cannot tell which step failed without inspection
throw new Error('pipeline failed', { cause: err });
}
}Prefer wrapping errors with cause (ES2022) to preserve the chain rather than flattening it.
Concurrency patterns
The default of sequential awaits is safe but slow. Common patterns:
| Pattern | Code shape | When to use |
|---|---|---|
| Parallel | `await Promise.all([f(), g()])` | Independent work, fail-fast acceptable |
| All-settled | `await Promise.allSettled([...])` | You want every outcome, partial success fine |
| Limited concurrency | semaphore / chunked `all` | Bounded resources (APIs, sockets) |
| Sequential | `for...of` with `await` | Order matters, or throttling via ordering |
| Fire-and-collect | kick off promises, `await` later | Start early, consume later |
async function limitedAll(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}A trap: Promise.all with an array you build via map(fn) — if fn is async, all the work has already started before all is called. To sequence instead, for (const x of items) await fn(x), or use the worker-pool above.
Return-await trade-offs
return promisevsreturn await promiseinsidetry: the latter is required for thecatchto see the rejection. Outsidetry,return promiseskips one microtask hop.- ESLint's
no-return-awaitis right in general, wrong intryblocks. for await...ofconsumes async iterables (next chapter's concern); remember it serializes by design.
Cancellation and timeouts
await gives you no cancellation. A promise you stop awaiting still runs to completion; its resources still leak. Mitigations:
function withTimeout(p, ms) {
return Promise.race([
p,
new Promise((_, rej) =>
setTimeout(() => rej(new Error('timeout')), ms)),
]);
}But the underlying work is not cancelled — for that you need AbortSignal at the API level (e.g., fetch(..., { signal })) and cooperation from the task itself. Design async APIs to accept a signal; don't rely on abandoning promises.
callout warning title: Top-promise awaits do not run in parallel
const x = await f(); const y = await g(); always sequences. If f and g are independent, start both first: const px = f(), py = g(); const x = await px, y = await py;. Sequential-by-default is the single most common async performance bug.
Diagram
Show diagram source
sequenceDiagram
participant C as Caller
participant F as async fn
participant Q as Microtask queue
C->>F: call
F->>F: sync prefix runs
F->>Q: register continuation on awaited promise
F-->>C: return pending promise
C->>C: continues immediately
Note over Q: ...promise resolves...
Q->>F: resume after await
F->>F: rest of function
F->>Q: resolve returned promiseExercise
Exercise
In the interleaving example, why does withdraw(80) twice not produce a rejected second call or a negative balance, and what invariant is broken instead?
Show answer
Neither withdrawal throws — each independently passes the balance >= amount check while the other is suspended at await dbLog. The invariant broken is "balance after withdrawal reflects all prior withdrawals": both subtract from the stale value of 100, so 160 was withdrawn from a 100 balance. The check and the mutation are not atomic across the await boundary.
Exercise
You replace return await p; inside a try with return p; and your catch stops firing for p's rejection. Explain precisely why.
Show answer
return p hands the pending promise straight back to the caller; the async function is no longer involved in its fate, so the rejection settles that returned promise rather than throwing into the current try block. return await p makes the function await the result, so a rejection surfaces as a throw at the await site and is caught by the surrounding catch.
Spotted a mistake, or code that does not work?