Lesson 07 / 17
async/await
An async function always returning a promise, await suspending the function, the syntactic layer's correspondence to a promise chain, and the bug of falling into unintentional sequential waiting.
Contents
Promises turned an asynchronous operation into a value, and combinators organized the waiting. Even so, how the code reads did not change: every step’s result is still processed inside a callback, and the flow does not run top to bottom in the source text.
This lesson builds the layer that expresses the same promise chains with
sequential-looking syntax. The point to emphasize should be said up front: async and
await do not introduce a new asynchrony mechanism. Both are a syntactic layer
built on top of promises; the event loop’s rules apply exactly as they are.
async Always Returns a Promise
A function defined with the async keyword returns a promise no matter what it
returns. If it returns an ordinary value, the promise fulfills with that value.
async function fixedMeasurement() { return { station: "A1", value: 21.4 }; } const returned = fixedMeasurement(); console.log("is the returned value a promise:", returned instanceof Promise); console.log("awaited result:", await returned);
is the returned value a promise: true
awaited result: { station: 'A1', value: 21.4 }
The reverse is true too: an error thrown inside an async function’s body does not propagate outward; it rejects the returned promise. The details of error propagation are the next lesson’s subject.
await Suspends the Function, Not the Thread
await waits for a promise to settle. While waiting, it suspends only the function
it is inside; the thread stays free and control returns to the calling code.
The observable consequence of this is: an async function’s body runs synchronously
up to its first await; everything after that turns into a microtask.
async function processMeasurement() { console.log("2 — async body starts synchronously"); await null; console.log("4 — continues in a microtask after the first await"); } console.log("1 — before the call"); processMeasurement(); console.log("3 — after the call");
1 — before the call 2 — async body starts synchronously 3 — after the call 4 — continues in a microtask after the first await
The awaited value was not even a promise; await null still adds a microtask turn.
Formally, the rule is: an await expression wraps the awaited value with
Promise.resolve and registers what follows as that promise’s callback.
Two async functions can advance side by side, not nested inside one another. Every
await hands control back to the event loop, and the other function’s next step
slots in between:
async function firstFlow() { console.log("A — step 1"); await null; console.log("A — step 2"); await null; console.log("A — step 3"); } async function secondFlow() { console.log("B — step 1"); await null; console.log("B — step 2"); await null; console.log("B — step 3"); } firstFlow(); secondFlow(); console.log("synchronous part finished");
A — step 1 B — step 1 synchronous part finished A — step 2 B — step 2 A — step 3 B — step 3
The two flows interleaved step by step; even so, the two never ran at the same
instant. The single-threaded model was not broken — only the split points were
explicitly marked with await expressions.
The Chain’s Counterpart
The same flow can be expressed with either syntax; the promise and the result they produce are identical.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } async function getAverage() { const a = await requestMeasurement("A1"); const b = await requestMeasurement("B2"); return (a.value + b.value) / 2; } function getAverageChained() { return requestMeasurement("A1").then((a) => requestMeasurement("B2").then((b) => (a.value + b.value) / 2), ); } console.log("await syntax:", (await getAverage()).toFixed(2)); console.log("chain syntax:", (await getAverageChained()).toFixed(2));
request started: A1 request started: B2 await syntax: 20.60 request started: A1 request started: B2 chain syntax: 20.60
The difference between the two forms is readability. In await syntax,
intermediate results are ordinary local variables; reaching the first step’s result
in the second step requires no nesting. The difference grows as the number of steps
grows: in chain syntax, every added step demands either a new indentation level or a
hand-carried intermediate object.
The Sequential-Wait Trap
await gives a sequential-looking reading, and that reading can be misleading: waits
written one after another actually run one after another.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } for (const station of ["A1", "B2", "C3"]) { const measurement = await requestMeasurement(station); console.log("arrived:", measurement.station, measurement.value); }
request started: A1 arrived: A1 21.4 request started: B2 arrived: B2 19.8 request started: C3 arrived: C3 23.1
The output’s order gives the diagnosis: every request starts only after the previous one’s result arrives. Even though the three stations are independent of each other, the waits do not overlap; the total time equals the sum of the three delays.
The fix is to separate starting the requests from waiting for them. Since the work starts once the promises are created, all of them are created first, then awaited at a single point.
const SOURCE = { A1: { delay: 30, value: 21.4 }, B2: { delay: 10, value: 19.8 }, C3: { delay: 20, value: 23.1 }, D4: { delay: 15, error: "sensor fault" }, }; function requestMeasurement(station) { console.log("request started:", station); return new Promise((resolve, reject) => { const record = SOURCE[station]; setTimeout(() => { if (record.error) reject(new Error(`${station}: ${record.error}`)); else resolve({ station, value: record.value }); }, record.delay); }); } const promises = ["A1", "B2", "C3"].map((station) => requestMeasurement(station).then((measurement) => { console.log("arrived:", measurement.station, measurement.value); return measurement; }), ); const measurements = await Promise.all(promises); console.log("total:", measurements.reduce((t, m) => t + m.value, 0).toFixed(1));
request started: A1 request started: B2 request started: C3 arrived: B2 19.8 arrived: C3 23.1 arrived: A1 21.4 total: 64.3
All three requests started immediately; the results arrived in order of delay. Total wait equals not the sum of the three delays but the largest one.
The criterion is a simple question: does this step genuinely depend on the
previous one’s result? If the answer is no, an await inside a loop is a bug. If
the answer is yes — for example, if the second station’s identity comes out of the
first measurement — sequential waiting is correct and necessary.
Top-Level await
At the module level, await can be used without being inside a function. The last
lines of this lesson’s examples use exactly this. The details tied to module forms
are the Modules, Tooling and the Ecosystem course’s subject; the only thing that
needs to be known here is that a top-level await delays the module’s evaluation.
Summary
- An async function always returns a promise; an error thrown in its body rejects the returned promise.
awaitsuspends only its own function; the thread stays free, and control returns to the calling code.- The body runs synchronously up to the first
await; what follows turns into a microtask — even awaiting a non-promise value adds a turn. awaitsyntax and chain syntax produce the same promise; the difference is readability.- Using
awaitinside a loop for independent jobs serializes the waits; the correct pattern is to start the jobs and await them at a single point.
Next Step
This lesson deliberately left the error path aside. The next lesson takes it up:
where an error thrown in an async body goes, when try/catch and catch syntax
diverge, in which case the combinators lose error information, and how a rejection no
one handles gets reported to the runtime. The measurement stream will also become
resilient to the faulty station there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.