Week 8: async/await & Error Handling

async/await is syntax sugar over exactly the Promises built last week — nothing new happens underneath, but async code finally reads top to bottom like ordinary synchronous code instead of a chain of .then() calls. This week finishes the async foundation: the syntax itself, real error handling, and the mistakes that catch almost everyone the first time they write it.

Phase 4 of 8 Week 8 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Rewrite a Promise chain as async/await, and explain why they're equivalent
  • Handle a rejected Promise with try/catch instead of .catch()
  • Tell the difference between accidentally-sequential and intentionally-parallel await calls

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:

a Promise chain...
function loadUser(id) {
  return fetchUser(id)
    .then((user) => fetchOrders(user.id))
    .then((orders) => console.log(orders))
    .catch((err) => console.log("Failed:", err.message));
}
...rewritten with async/await
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:

catching a rejected Promise
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:

accidentally sequential (slow)
async function loadDashboard() {
  const user = await fetchUser(1);       // waits ~300ms
  const products = await fetchProducts(); // THEN waits another ~300ms
  return { user, products };              // total: ~600ms
}
intentionally parallel (fast)
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:

mistake: forgetting await entirely
async function loadUser(id) {
  const user = fetchUser(id); // missing await!
  console.log(user.name);     // TypeError: user is a Promise, not the resolved value
}
mistake: using .map() with an async callback and expecting it to wait
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

Hands-on

Rewrite last week's Promise chain with async/await

Convert Week 7's product/discount chain, then add a parallel-fetch step.

Requirements:

  1. Reuse (or rewrite) Week 7's fetchProduct(id) and fetchDiscount(price) functions.
  2. Write an async function getFinalPrice(id) that awaits fetchProduct, then awaits fetchDiscount with the result, wrapped in try/catch — return null and log a message on failure instead of crashing.
  3. 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.
  4. Write async function loadProductPage(id) that fetches the final price and the reviews in parallel using Promise.all, since neither depends on the other.
  5. Call getFinalPrice(-1) (an invalid ID) directly to confirm your try/catch actually catches the rejection instead of throwing an unhandled error.
Hint

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?

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?

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?

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?

.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.