1. async/await Syntax
An async function always returns a Promise, and inside it, the
await keyword pauses execution until a Promise settles — without
blocking the rest of the program, exactly like a .then() callback would:
function loadUser(id) {
return fetchUser(id)
.then((user) => fetchOrders(user.id))
.then((orders) => console.log(orders))
.catch((err) => console.log("Failed:", err.message));
}
async function loadUser(id) {
try {
const user = await fetchUser(id); // pauses here until fetchUser resolves
const orders = await fetchOrders(user.id);
console.log(orders);
} catch (err) {
console.log("Failed:", err.message);
}
}
Both versions do exactly the same thing — await is quietly still using
.then() underneath, and try/catch is quietly
still catching a rejection, the same way .catch() does. What's genuinely
different is readability: the async/await version reads
top to bottom, in the same shape as ordinary synchronous code, instead of a chain of
callbacks.
await only works inside an async function
Using await at the top level of a regular (non-async) function is a syntax error. This is why main()-style wrapper functions marked async are so common — they exist purely to give the rest of the code a place to use await.
2. Error Handling With try/catch
A rejected awaited Promise throws — exactly like a
synchronous error — which is why try/catch, not
.catch(), is the standard way to handle failures in
async/await code:
async function loadUser(id) {
try {
const user = await fetchUser(id); // throws if fetchUser's Promise rejects
return user;
} catch (err) {
console.log("Could not load user:", err.message);
return null; // a sensible fallback, instead of letting the error crash the caller
}
}
Anything thrown or rejected anywhere inside the try block —
across any number of await lines — is caught by the single
catch block, the same way one .catch() covers an entire
Promise chain. If a function doesn't wrap its await calls in
try/catch at all, an unhandled rejection propagates up to
whatever called it — which is itself a valid choice sometimes, but should be
deliberate, not accidental.
3. Awaiting Multiple Promises
awaiting one Promise after another looks reasonable, but silently costs
time when the operations don't actually depend on each other:
async function loadDashboard() {
const user = await fetchUser(1); // waits ~300ms
const products = await fetchProducts(); // THEN waits another ~300ms
return { user, products }; // total: ~600ms
}
async function loadDashboard() {
const [user, products] = await Promise.all([
fetchUser(1), // both start immediately, at the same time
fetchProducts(),
]);
return { user, products }; // total: ~300ms -- roughly the slower of the two, not the sum
}
fetchProducts() in the first version doesn't even start until
fetchUser(1) has fully finished, purely because of the order the
await keywords appear on the page — even though the two calls have
nothing to do with each other. Promise.all starts every Promise in the
array immediately and waits for all of them together, so independent operations run
concurrently instead of piling their durations on top of each other. Reach for
sequential await only when one step genuinely needs the previous step's
result — like loadUser's fetchOrders(user.id) earlier,
which needs user.id from the first call.
4. Common Async Mistakes
A few habits worth building deliberately, since the alternative fails quietly rather than with an obvious error:
async function loadUser(id) {
const user = fetchUser(id); // missing await!
console.log(user.name); // TypeError: user is a Promise, not the resolved value
}
async function loadAll(ids) {
// .map()'s callback returns a Promise each time, but .map() itself has no
// idea it's async -- it doesn't wait, so `results` is an array of PROMISES.
const results = ids.map(async (id) => await fetchUser(id));
console.log(results); // [Promise, Promise, Promise] -- not the actual users
// Fix: wrap the whole array of Promises in Promise.all
const users = await Promise.all(ids.map((id) => fetchUser(id)));
console.log(users); // the actual resolved user objects
}
Forgetting await leaves you holding a Promise object instead of its
resolved value — usually surfacing as a confusing error on whatever line tries to use
it. The .map() mistake is subtler: .map() has no special
awareness of async callbacks, so it happily builds an array of
still-pending Promises and moves on immediately; Promise.all around the
whole thing is what actually waits for every one of them to resolve.
5. Hands-on Exercise
Rewrite last week's Promise chain with async/await
Convert Week 7's product/discount chain, then add a parallel-fetch step.
Requirements:
- Reuse (or rewrite) Week 7's
fetchProduct(id)andfetchDiscount(price)functions. - Write an
async function getFinalPrice(id)thatawaitsfetchProduct, thenawaitsfetchDiscountwith the result, wrapped intry/catch— returnnulland log a message on failure instead of crashing. - Write a second function,
fetchReviews(id), returning a Promise that resolves with a fake array of review strings after a short delay — independent of the product/discount data. - Write
async function loadProductPage(id)that fetches the final price and the reviews in parallel usingPromise.all, since neither depends on the other. - Call
getFinalPrice(-1)(an invalid ID) directly to confirm yourtry/catchactually catches the rejection instead of throwing an unhandled error.
Step 4 is the "intentionally parallel" pattern from this lesson: const [price, reviews] = await Promise.all([getFinalPrice(id), fetchReviews(id)]) — both start at the same time instead of one waiting for the other to finish first.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Under the hood, is async/await a completely different mechanism from Promises, or built on top of them?
Under the hood, is async/await a completely different mechanism from Promises, or built on top of them?
Built on top of them — it's syntax sugar. An async function always returns a Promise, and await is a more readable way of writing what .then() already does: pause here until this Promise settles, then continue with its resolved value. Nothing new happens underneath; only the syntax for expressing it changes.
Q2
Why does a rejected, awaited Promise need to be caught with try/catch instead of just letting it fail?
Why does a rejected, awaited Promise need to be caught with try/catch instead of just letting it fail?
A rejected awaited Promise throws an exception at that line, exactly like a synchronous error — without a try/catch around it, that exception propagates up uncaught, which can crash the calling code or leave a Node process reporting an unhandled promise rejection. Wrapping the await in try/catch lets the function handle the failure deliberately (e.g. return a fallback value) instead.
Q3
Two unrelated async calls are written as await fetchA(); await fetchB(); one after another. What's the problem, and how is it fixed?
Two unrelated async calls are written as await fetchA(); await fetchB(); one after another. What's the problem, and how is it fixed?
fetchB() doesn't even start until fetchA() has fully resolved, even though the two calls don't depend on each other — their durations simply add up instead of overlapping. The fix is Promise.all([fetchA(), fetchB()]), which starts both immediately and waits for both together, so the total time is roughly the slower of the two rather than the sum of both.
Q4
Why does ids.map(async (id) => await fetchUser(id)) return an array of Promises instead of an array of resolved users?
Why does ids.map(async (id) => await fetchUser(id)) return an array of Promises instead of an array of resolved users?
.map() has no built-in awareness that its callback is async — every async function call returns a Promise immediately, and .map() just collects whatever each callback call returns without waiting for any of them to settle. Wrapping the whole result in Promise.all(...) is what actually waits for every Promise in that array to resolve before continuing.