081: Async/Await and Concurrency
Learning outcomes
By the end, you can:
- explain what an
asyncfunction returns; - use
awaitto consume a Promise without claiming it blocks the whole program; - handle rejected Promises with
try,catch, andfinally; - choose sequential execution for dependent work and
Promise.allconcurrency for independent work; - return an async result to the caller and avoid floating Promises.
Retrieval warm-up
- What are the three Promise states?
- What determines the state of the new Promise returned by
then? - Why must a dependent Promise be returned from a
thenhandler?
Vocabulary
- Async function: Function declared with async that always returns a promise from its body result. — Source: MDN: async function
- Await expression: Suspends the async function until its operand settles, resuming with value or thrown reason. — Source: MDN: await
- Continuation: Portion of the async function resuming after await completes. — Source: MDN: await
- Sequential: Awaiting steps one-by-one; total time is the sum of each step. — Source: MDN: Promise.all
- Concurrent: Operations overlapping in time even though JS executes instructions serially. — Source: MDN: Promise.all
- Parallel: True simultaneous execution across cores/workers, beyond single-threaded JS. — Source: MDN: Web Workers API
- Fail-fast: Promise.all rejects immediately on the first member rejection. — Source: MDN: Promise.all
- Continuation (official): "The continuation is the portion of an async function that resumes after an await." — Source: MDN: await — Continuation
- Fail-fast (official): "Fail-fast means the first rejection or error stops further processing, as in Promise.all." — Source: MDN: Promise.all — Fail-fast
Mental model: readable Promise choreography
async and await are syntax built on Promises. They do not replace Promise states or error behavior.
async function answer() {
return 42;
}
const result = answer();
console.log(result instanceof Promise); // true
result.then(console.log); // later: 42
Calling an async function runs its body synchronously until it returns, throws, or reaches an await. At an await, JavaScript obtains a Promise for the expression. If pending, the function yields control to its caller. Other runnable work can continue. When the Promise settles, the function's continuation is scheduled; fulfillment produces the awaited value, while rejection throws at the await point.
Therefore, say "await pauses this async function" rather than "await pauses JavaScript". It does not make CPU-heavy code non-blocking. Code before the first await still runs synchronously, and resumed code still occupies the event loop while running.
Self-check: cover the lines after an await and predict what the caller receives when execution reaches that point. The answer is still the async function's Promise. Then uncover the continuation and predict which value appears if the awaited Promise fulfills and which path runs if it rejects.
Beginner self-study example: a two-step profile
The example is deterministic and needs no network.
function after(delay, value, shouldFail = false) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`Could not load ${value}`));
} else {
resolve(value);
}
}, delay);
});
}
async function showProfile() {
console.log("Loading profile...");
try {
const user = await after(200, { id: 7, name: "Asha" });
const message = await after(100, `Welcome, ${user.name}`);
console.log(message);
return user;
} catch (error) {
console.error(`Profile error: ${error.message}`);
return null;
} finally {
console.log("Loading finished");
}
}
showProfile().then((user) => {
console.log(user ? `User ID: ${user.id}` : "No user");
});
console.log("Page remains available");
Step-by-step explanation
showProfile()immediately returns a Promise to its caller.- Its body first prints
Loading profile...synchronously. after(...)starts a timer and returns a pending Promise.awaitpauses onlyshowProfileand returns control.- The script prints
Page remains available. - The first Promise fulfills.
showProfileresumes with the user object. - The second operation must use
user.name, so starting it after the first is genuinely sequential. - The function returns
user; its returned Promise fulfills with that object. finallyruns before the caller'sthenobserves completion.
Expected output
Loading profile... Page remains available Welcome, Asha Loading finished User ID: 7
Set the first call to after(200, "user", true). The rejection behaves like a throw at await; control enters catch, then finally, and the returned null fulfills the async function's Promise. If the caller must see a rejection, omit return null and throw error after logging.
Error boundaries
Place try around the awaits whose failures you can meaningfully handle:
async function loadSettings() {
try {
return await readSettings();
} catch (error) {
console.error("Settings unavailable", error);
return { theme: "system" };
}
}
This intentionally recovers with fallback data. By contrast:
async function loadSettings() {
try {
return await readSettings();
} catch (error) {
console.error("Settings unavailable", error);
throw error;
}
}
This reports and preserves rejection. An async function with an uncaught throw returns a rejected Promise. The caller must await, return, or deliberately catch that Promise.
Intermediate example: sequential versus concurrent
Use sequential awaits when later work depends on earlier output:
async function dependentReport() {
const user = await after(300, { id: 5, name: "Lee" });
const orders = await after(300, [
{ userId: user.id, total: 18 },
{ userId: user.id, total: 12 },
]);
return { user, orders };
}
The second request conceptually needs user.id; about 600 ms is expected here, though timers are not exact.
If operations are independent, start both and wire their error handling together immediately with Promise.all:
async function dashboard() {
const started = performance.now();
const [weather, notices] = await Promise.all([
after(400, "Weather: sunny"),
after(250, "Notices: 2"),
]);
console.log(weather);
console.log(notices);
console.log(`About ${Math.round(performance.now() - started)} ms`);
}
dashboard().catch((error) => console.error(error.message));
Expected output is the two strings in array order and elapsed time around the slower 400 ms operation, not their 650 ms sum. Promise.all preserves input order regardless of completion order.
Avoid this accidental serialization:
const weather = await getWeather();
const notices = await getNotices();
Also avoid starting two Promises and awaiting them separately if either can reject before its await is connected. Promise.all([getWeather(), getNotices()]) attaches handling to both as they start.
Choosing a combinator
Promise.all: every result is required; reject if any fails.Promise.allSettled: collect every success and failure.Promise.any: use the first fulfillment; reject if all reject.Promise.race: use the first settlement, fulfillment or rejection.
None cancels losing operations. Use an API's cancellation mechanism when cancellation matters.
Optional advanced stable example: limited batches
Starting thousands of requests together can overwhelm a browser or service. A simple stable compromise is batching:
async function processInBatches(items, batchSize) {
const results = [];
for (let index = 0; index < items.length; index += batchSize) {
const batch = items.slice(index, index + batchSize);
const values = await Promise.all(
batch.map((item) => after(100, item.toUpperCase())),
);
results.push(...values);
}
return results;
}
processInBatches(["a", "b", "c", "d", "e"], 2).then(console.log);
Expected output after roughly three batches:
["A", "B", "C", "D", "E"]
Items in each batch overlap; batches are sequential. This is not a full task pool, but it clearly limits in-flight operations.
Common mistakes and debugging
- Forgetting
async:awaitis valid inside async functions and at top level in JavaScript modules, not ordinary classic-script top level. - Forgetting
await: a variable contains a Promise rather than its value. Inspect it or use type tooling. - Forgetting to return the async call:
function save() { saveAsync(); }floats work. Usereturn saveAsync()or make the boundary async and await it. - Using
forEachwith an async callback:forEachdoes not await callbacks. Usefor...offor sequential work orPromise.all(items.map(async ...))for concurrent work. - Serializing independent work: identify dependencies before placing awaits.
- Catching too broadly: a giant
trycan hide programming errors and make recovery unclear. - Assuming
Promise.allcancels: it rejects early, while other operations generally continue.
Set breakpoints after each await and inspect the Network panel in later fetch examples. Always terminate top-level calls with await, return, or a rejection handler.
Security and performance
Concurrency can improve elapsed time but increases resource usage. Bound fan-out, respect rate limits, and do not retry non-idempotent actions blindly. Never include passwords, API keys, or personal information in errors shown to users. A catch block should distinguish expected operational failures from bugs when possible. Async syntax does not protect shared UI state from stale results; cancellation or request identity checks may be needed when users trigger overlapping loads.
Exercises
Level 1: rewrite
Rewrite with async/await:
function getLabel() {
return Promise.resolve("Ready").then((value) => value.toUpperCase());
}
async function getLabel() {
const value = await Promise.resolve("Ready");
return value.toUpperCase();
}
Calling either version returns a Promise fulfilled with READY.
Level 2: handle failure
Write loadName that awaits after(100, "name", true), returns "Guest" on failure, and always logs Complete.
async function loadName() {
try {
return await after(100, "name", true);
} catch (error) {
return "Guest";
} finally {
console.log("Complete");
}
}
Level 3: remove accidental serialization
Make these independent operations concurrent:
const profile = await getProfile();
const messages = await getMessages();
return { profile, messages };
const [profile, messages] = await Promise.all([
getProfile(),
getMessages(),
]);
return { profile, messages };
Use this only if neither call requires the other's result.
Recap
Async functions always return Promises. await suspends one async function's continuation, not the whole runtime. Rejection throws at the await point, making focused try/catch/finally useful. Sequence dependent operations; start independent operations together with Promise.all; bound concurrency when fan-out is large.
Official references
- MDN: async function
- MDN: await
- MDN: Promise.all
- MDN: Using promises - composition
- ECMAScript specification: Async function definitions
Streams and backpressure
An async iterator is a convenient application-level stream, but a platform stream also exposes explicit flow control. A ReadableStream producer should not enqueue without regard for controller.desiredSize; a consumer should use pipeTo/pipeThrough or getReader() and release the reader in cleanup.
const source = new ReadableStream({
start(controller) {
controller.enqueue("one\n");
controller.enqueue("two\n");
controller.close();
},
});
const lines = [];
for await (const chunk of source) lines.push(chunk.trim());
console.assert(lines.join(",") === "one,two");
For a real producer, pause or stop production when desiredSize <= 0, resume from pull(), and implement cancel(reason) to release sockets, timers, or file handles. pipeTo propagates errors and cancellation according to its options; a manually written while (true) loop must reproduce those guarantees itself. Backpressure limits queued data, but it does not limit total data, validate content, or cancel an upstream server that ignores the signal.
Test a slow writable sink, an error in the transform, consumer cancellation, and a producer that attempts to enqueue after close. Assert that cleanup runs once and that memory does not grow with an unbounded source. Interview question: why is await stream.getReader().read() not equivalent to buffering the entire response? It requests one chunk at a time, allowing the consumer's pace to control the producer.
Retries, backoff, and bounded concurrency
Retry only transient failures and only when repeating the operation is safe. A network timeout after a POST does not prove the server did nothing; retrying it can create a duplicate. Prefer idempotency keys or server-supported conditional requests for uncertain writes.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function retry(operation, {
attempts = 3,
baseDelay = 100,
shouldRetry = () => true,
} = {}) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await operation(attempt);
} catch (error) {
if (attempt === attempts || !shouldRetry(error, attempt)) throw error;
const jitter = Math.random() * baseDelay;
await sleep(baseDelay * 2 ** (attempt - 1) + jitter);
}
}
}
let failures = 0;
retry(() => {
if (failures++ < 2) throw new Error("temporary");
return "success";
}, { attempts: 3, baseDelay: 10 }).then(console.log);
Exponential backoff reduces synchronized retry storms; jitter prevents many clients from retrying on the same schedule. Production code should honor Retry-After, cap the maximum delay, stop on cancellation, and classify status codes rather than retry every exception.
For many independent items, a small worker pool bounds in-flight work without serializing everything:
async function mapConcurrent(items, limit, worker) {
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be positive");
const results = new Array(items.length);
let next = 0;
async function consume() {
while (true) {
const index = next++;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, consume));
return results;
}
mapConcurrent([1, 2, 3, 4], 2, (value) => sleep(10).then(() => value * 2))
.then((values) => console.assert(values.join(",") === "2,4,6,8"));
The output order is input order even if completion order differs. A rejected worker rejects the pool, but already-started workers generally continue; add an AbortSignal if coordinated cancellation is required.
Interview questions
- Why is
awaitin a loop sometimes correct? It preserves dependency order and limits concurrency when the next operation needs the previous result. - Why is
Promise.all(items.map(work))dangerous for 100,000 items? It starts all work and may exhaust sockets, memory, rate limits, or service capacity. - Which failures should be retried? Bounded, classified transient failures such as selected
408,429, or503, subject to method safety and server policy. - What test proves the pool is bounded? Track
active++on start andactive--infinally; assert the maximum never exceeds the chosen limit.
let active = 0;
let maximum = 0;
await mapConcurrent([1, 2, 3, 4, 5], 2, async (value) => {
active += 1;
maximum = Math.max(maximum, active);
try {
await sleep(1);
return value;
} finally {
active -= 1;
}
});
console.assert(maximum <= 2);
