081: Async/Await and Concurrency
Learning outcomes
By the end of this lesson, you should be able to:
- explain what an
asyncfunction returns; - use
awaitto consume a Promise without suggesting that it blocks the entire program; - handle rejected Promises with
try,catch, andfinally; - choose sequential execution when work is dependent and
Promise.allconcurrency when work is independent; - return an async result to the caller and avoid leaving Promises floating without a handler.
Retrieval warm-up
Before starting, retrieve the Promise model you already have:
- 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: A function declared with
asyncthat always returns a Promise representing the result of its body. — Source: MDN: async function - Await expression: An expression that suspends the async function until its operand settles, then resumes with the fulfillment value or throws the rejection reason. — Source: MDN: await
- Continuation: The portion of an async function that resumes after an
awaitcompletes. — Source: MDN: await - Sequential: Awaiting steps one at a time, so the total time is approximately the sum of the individual steps. — Source: MDN: Promise.all
- Concurrent: Operations whose work overlaps in time, even though JavaScript executes instructions serially on its main thread. — Source: MDN: Promise.all
- Parallel: True simultaneous execution across cores or workers, which is beyond ordinary single-threaded JavaScript execution. — Source: MDN: Web Workers API
- Fail-fast:
Promise.allrejects as soon as one of its member Promises rejects. — 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
When Promise chains become difficult to read, async and await provide a more direct way to write the same choreography. They are syntax built on top of Promises, not a replacement for Promise states or Promise 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 starts its body synchronously. It continues synchronously until the function returns, throws, or reaches an await. At that point, JavaScript obtains a Promise for the expression. If that Promise is pending, the async function yields control to its caller, so other runnable work can continue. Once the Promise settles, the function's continuation is scheduled. Fulfillment supplies the awaited value; rejection behaves like a throw at the await expression.
This is the distinction that prevents a common debugging mistake: say "await pauses this async function," not "await pauses JavaScript." Awaiting does not make CPU-heavy work non-blocking. Code before the first await still runs synchronously, and code resumed after an await still occupies the event loop while it executes.
For a quick self-check, cover the lines after an await and predict what the caller receives when execution reaches that point. The caller still receives the async function's Promise. Then uncover the continuation and predict which value appears when the awaited Promise fulfills and which path runs when it rejects.
Beginner self-study example: a two-step profile
This example is deterministic and does not depend on a network service, so you can concentrate on the ordering and error behavior.
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. The caller does not receive the eventual user object directly.- The function body first prints
Loading profile...synchronously. after(...)starts a timer and returns a pending Promise. Theawaitsuspends onlyshowProfile, returning control to the caller.- The script can therefore print
Page remains availablewhile the timer is pending. - When the first Promise fulfills,
showProfileresumes with the user object. - The second operation needs
user.name, so it cannot start until the first operation has completed. That dependency makes these steps genuinely sequential. - The function returns
user, so its returned Promise fulfills with that object. finallyruns before the caller'sthenhandler observes completion.
Expected output
Loading profile...
Page remains available
Welcome, Asha
Loading finished
User ID: 7
Change the first call to after(200, "user", true). Its rejection behaves like a throw at await, so control moves into catch, then through finally. Because the function returns null from catch, the async function's Promise fulfills with null. If the caller needs to observe a rejection instead, omit return null and throw error after logging it.
Error boundaries
Put try around the awaits whose failures you can actually handle. The boundary should be narrow enough that the fallback is meaningful and does not accidentally hide unrelated programming errors:
async function loadSettings() {
try {
return await readSettings();
} catch (error) {
console.error("Settings unavailable", error);
return { theme: "system" };
}
}
This version deliberately recovers with fallback data. A different contract is to report the error while preserving the rejection:
async function loadSettings() {
try {
return await readSettings();
} catch (error) {
console.error("Settings unavailable", error);
throw error;
}
}
Here the error is logged but not converted into a successful fallback. An async function with an uncaught throw returns a rejected Promise. Its caller must await that Promise, return it, or deliberately catch it.
Intermediate example: sequential versus concurrent
Use sequential awaits when a later operation needs data produced by an earlier one:
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, so beginning it earlier would be incorrect. About 600 ms is expected in this example, although timer delays are not exact.
When operations are independent, start them together and connect their error handling 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));
The two strings are returned in array order, and the elapsed time should be around the slower 400 ms operation rather than the 650 ms sum. Promise.all preserves the input order even when the individual operations complete in a different order.
This version accidentally serializes independent work:
const weather = await getWeather();
const notices = await getNotices();
There is another subtle failure mode. If you start two Promises and await them separately, one may reject before execution reaches its await. Promise.all([getWeather(), getNotices()]) wires handling to both Promises as they are started.
Choosing a combinator
Promise.all: every result is required; reject if any member fails.Promise.allSettled: collect the result of every operation, whether it succeeded or failed.Promise.any: use the first fulfillment; reject with an aggregate failure if all members reject.Promise.race: use the first settlement, whether that settlement is fulfillment or rejection.
None of these combinators cancels the operations that lose the race or are still running after another member rejects. When cancellation matters, use the cancellation mechanism provided by the API, commonly an AbortSignal.
Optional advanced stable example: limited batches
Starting thousands of requests at once can overwhelm a browser, a service, or both. Batching is a simple, stable compromise:
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:
["A", "B", "C", "D", "E"]
The result appears after roughly three batches. Items within one batch overlap, while the batches themselves run sequentially. This is not a full task pool, but it gives you a clear limit on the number of in-flight operations.
Common mistakes and debugging
- Forgetting
async:awaitis valid inside async functions and at top level in JavaScript modules, but not at ordinary classic-script top level. - Forgetting
await: a variable contains a Promise rather than its fulfillment value. Inspect the value or use type tooling to expose the mismatch. - Forgetting to return the async call:
function save() { saveAsync(); }floats the work. Usereturn saveAsync()or make the boundary async and await it. - Using
forEachwith an async callback:forEachdoes not await its callbacks. Usefor...offor sequential work orPromise.all(items.map(async ...))for concurrent work. - Serializing independent work: identify dependencies before placing awaits; not every line that returns a Promise must wait for the previous line.
- Catching too broadly: a giant
tryblock can hide programming errors and make the intended recovery unclear. - Assuming
Promise.allcancels: it rejects early, but the other operations generally continue running.
For debugging, set breakpoints after each await and, in later fetch-based examples, inspect the Network panel. At every top-level async boundary, finish the chain with await, return, or a rejection handler.
Security and performance
Concurrency can reduce elapsed time, but it also increases resource usage. Bound fan-out, respect rate limits, and do not blindly retry non-idempotent actions. Never place passwords, API keys, or personal information in errors shown to users. When possible, have a catch block distinguish expected operational failures from bugs. Async syntax also does not protect shared UI state from stale results: if users can trigger overlapping loads, cancellation or request-identity checks may be necessary.
Exercises
Level 1: rewrite
Rewrite this function 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 so that it awaits after(100, "name", true), returns "Guest" when the operation fails, 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
These operations are independent. Make them 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 form only when neither call requires the result of the other.
Recap
An async function always returns a Promise. await suspends the continuation of one async function, not the whole runtime. A rejection throws at the await point, which makes a focused try/catch/finally boundary useful. Keep dependent operations sequential, start independent operations together with Promise.all, and bound concurrency when fan-out becomes 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 useful application-level stream abstraction, but platform streams expose explicit flow control as well. A ReadableStream producer should pay attention to controller.desiredSize instead of enqueueing without limit. A consumer should generally use pipeTo, pipeThrough, or getReader(), and release a reader during 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");
In 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 the total amount of data, validate its 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 tries to enqueue after close. Assert that cleanup runs exactly once and that memory does not grow with an unbounded source. A useful interview question is: 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 influence 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 establish that the server did nothing; retrying may create a duplicate. For uncertain writes, prefer idempotency keys or server-supported conditional requests.
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, while jitter prevents many clients from retrying on exactly the same schedule. Production code should honor Retry-After, cap the maximum delay, stop when cancellation is requested, and classify status codes instead of retrying every exception.
For many independent items, a small worker pool bounds in-flight work without forcing everything to run serially:
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 returned order follows the input order even if individual items finish in a different order. If a worker rejects, the pool rejects, but workers that have already started generally continue. Add an AbortSignal when 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 the work at once and may exhaust sockets, memory, rate limits, or service capacity. - Which failures should be retried? Bounded, classified transient failures such as selected
408,429, or503responses, subject to method safety and server policy. - What test proves the pool is bounded? Increment
activewhen work starts and decrement it infinally; assert that 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);
