Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Composing Promises with Async/Await and Error Propagation

Hello again! Last lesson, you traced how a function’s call style determines this, especially when a method becomes a callback. This lesson turns to a different question about functions passed around in JavaScript: what happens to their results and errors when the work finishes later?

In Node and React code, you often need one asynchronous result before starting another operation, while other operations can run independently. By the end of this lesson, you should be able to compose both kinds of work with promises and async/await—and explain whether a failure reaches the caller, is deliberately recovered from, or is accidentally lost.


A promise chain is a chain of returned outcomes

A promise represents an eventual result: it may still be pending, or it may have settled by being fulfilled with a value or rejected with a reason. Calling .then() does not simply add a callback to the same promise. It returns a new promise whose outcome depends on what the callback does.

A simplified view of fulfillment and rejection handlers leading to another promise. The important feature for composition is the new promise returned by `.then()` or `.catch()`; a handler can also throw, causing that new promise to reject.

Suppose an API gives us promise-returning getUser(id) and getOrders(userId) functions. Orders require the user’s ID, so the operations must happen in sequence:

function loadOrderIds(id) {
  return getUser(id)
    .then((user) => getOrders(user.id))
    .then((orders) => orders.map((order) => order.id));
}

The first handler returns the promise from getOrders. The promise produced by that .then() follows the returned promise: the next handler receives the orders only when they are available. If getUser or getOrders rejects, the success handlers downstream are skipped until a rejection handler is found. If orders.map(...) throws, that error also rejects the promise returned by its .then().

There are two levels of return to notice. The return inside the first handler connects getOrders to the chain. The return from loadOrderIds gives the whole chain to its caller:

loadOrderIds(7)
  .then((ids) => console.log(ids))
  .catch((error) => console.error("Could not load orders:", error));

Without the inner return, the chain can report success before the order lookup finishes:

function brokenLoadOrderIds(id) {
  return getUser(id).then((user) => {
    getOrders(user.id); // Started, but not returned
  });
}

Here, the handler finishes with undefined, so the promise returned by .then() fulfills with undefined. A rejection from the detached getOrders promise is not part of the returned chain. The caller’s .catch() therefore cannot handle it. This is a common interview bug: attaching a final catch does not repair work that was never connected to the promise you returned.

Read MDN’s explanation of how exceptions and rejections move through a chain. Compare its final .catch() with the try/catch version; we will use the same principle with await shortly.

Using promises - JavaScript - MDN Web Docs

MDN’s “Using promises” guide explains why one downstream error handler can cover failures from several composed steps.

In Error handling, begin at the paragraph recalling the repeated failureCallback in the earlier callback example. Read the comparison through the explanation that promises also capture thrown exceptions. Focus on why an exception in a handler reaches the chain’s final catch.

A useful rule is: return the promise for any asynchronous work whose result or failure belongs to this operation.


async/await changes the notation, not the contract

We can write the same dependent operations without .then() callbacks:

async function loadOrderIds(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  return orders.map((order) => order.id);
}

Calling loadOrderIds(7) still returns a promise—not an array of IDs directly. An async function always returns a promise: returning an ordinary value fulfills it with that value, while an uncaught throw rejects it. At each await, execution of this function pauses until the awaited promise settles. JavaScript can run other work in the meantime; await does not stop the entire Node process or browser.

Watch Fireship’s “The Async Await Episode I Promised” for its short transition from promise-returning functions to await. The lesson’s code above is the same composition principle in a dashboard-style workflow rather than the video’s fruit example.

The Async Await Episode I Promised

Fireship introduces async as a way to return a promise and enable await.

Watch async fundamentals. Concentrate on what the caller receives when an async function returns a value; do not mistake the more synchronous-looking syntax for a synchronous return.

The distinction matters at the call site:

const pendingIds = loadOrderIds(7); // A promise, not the IDs

pendingIds
  .then((ids) => console.log(ids))
  .catch((error) => console.error(error));

Another async function could instead use const ids = await loadOrderIds(7). In either style, the caller must do something with the returned promise if it needs the result or must handle failure.


Decide where an error should stop—and where it should travel

A rejected promise encountered at await behaves like a throw at that line. If nothing catches it inside the async function, the function’s returned promise rejects:

async function loadOrderIds(id) {
  const user = await getUser(id);       // A rejection throws here
  const orders = await getOrders(user.id); // Or here
  return orders.map((order) => order.id);  // A synchronous throw also rejects
}

loadOrderIds(7).catch((error) => {
  console.error("Could not load orders:", error);
});

You can catch an error to add context while preserving failure for the caller:

async function loadOrderIds(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return orders.map((order) => order.id);
  } catch (error) {
    console.error("Order loading failed:", error);
    throw error;
  }
}

The throw error is consequential. If the catch block only logs, the function reaches its end and its promise fulfills with undefined. A caller may then proceed as though loading succeeded. Returning a fallback is also possible, but it is a deliberate change from rejection to fulfillment—not merely “handling” the same error.

There is a subtle boundary when returning a promise from inside try:

async function loadOrders(id) {
  try {
    return getOrders(id);
  } catch (error) {
    // Does not catch a later rejection from getOrders(id)
    throw error;
  }
}

The returned promise still makes loadOrders reject if getOrders rejects, so propagation to the caller is intact. But the local catch only covers a synchronous error while calling getOrders; it does not observe that promise’s later rejection. If this local catch must log or transform the rejection, use return await getOrders(id) inside the try. Otherwise, returning the promise directly is fine and the caller can handle it.

An API boundary offers a practical reason to preserve this contract. With fetch, a network failure rejects, but an HTTP error response such as 404 does not by itself make fetch reject. You must decide whether that response is a failure for your function:

async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);

  if (!response.ok) {
    throw new Error(`User request failed: ${response.status}`);
  }

  return response.json();
}

Now getUser rejects on a network failure, an HTTP error you classified as failure, or failed JSON parsing. A caller awaiting it can handle those through one error path.

Fireship’s error-handling segment contrasts the two choices at a catch block: provide a fallback value or throw so the consumer receives a rejection.

The Async Await Episode I Promised

The video revisits try/catch inside an async function, this time focusing on what its caller observes.

Watch error choices. Pay particular attention to the difference between returning from a catch and throwing from it.

Sometimes recovery is appropriate, but scope the catch to the optional work. Imagine recommendations are optional, while loading the user and orders is essential:

async function loadDashboard(id) {
  const user = await getUser(id);

  let recommendations;
  try {
    recommendations = await getRecommendations(user.id);
  } catch (error) {
    console.warn("Recommendations unavailable:", error);
    recommendations = [];
  }

  const orders = await getOrders(user.id);
  return { user, orders, recommendations };
}

A failure from getRecommendations produces an intentional empty list. A failure from getUser or getOrders still rejects loadDashboard’s promise. Wrapping all three calls in a catch that returns an empty dashboard would obscure which critical operation failed.

For a second explanation of this distinction, read MDN’s nested-error-handling example. Its optional steps are intentionally enclosed in a smaller error-handling region.

Using promises - JavaScript - MDN Web Docs

MDN shows how to limit recovery to optional work without silencing a critical failure.

In Nesting, start with the paragraph explaining that nesting limits the scope of catch handlers. Read the optional-step example, ending with the explanation that failure of doSomethingCritical() reaches the outer catch rather than the inner one. Follow both the nested promise chain and its nested try/catch counterpart; focus on which operations each catch can handle.


Compose independent work without making it wait unnecessarily

getOrders and getRecommendations both need the user ID, but neither needs the other operation’s result. In the previous example, recommendations finish before orders even start. If both are required, start them together and await their combined outcome:

async function loadDashboard(id) {
  const user = await getUser(id);

  const [orders, recommendations] = await Promise.all([
    getOrders(user.id),
    getRecommendations(user.id),
  ]);

  return { user, orders, recommendations };
}

The user lookup is necessarily first. After that, both calls start without waiting for each other. Promise.all fulfills with results in input order, regardless of which operation finishes first. If either promise rejects, the Promise.all promise rejects, and await throws; absent a catch here, loadDashboard rejects for its caller.

Watch the next part of Fireship’s example for the difference between dependent awaits and independent operations.

The Async Await Episode I Promised

Fireship compares sequential await calls with awaiting independent promises together.

Watch concurrent composition. Focus on the test for dependency: does the second call need the first call’s value, or can both begin once their inputs are known?

Read MDN’s Promise.all description for an important limit: rejection does not cancel the other operations.

Using promises - JavaScript - MDN Web Docs

MDN describes the result and failure behavior of Promise.all, including what happens to work still in progress.

In Composition, begin with the example that starts several operations together. Read the combined-result discussion through the mention of Promise.allSettled(). Note that Promise.all rejects when an input rejects, while the other operations continue running; it does not cancel them.

What if recommendations are optional and we want them to run alongside required orders? Recover from the optional promise before it contributes to Promise.all:

async function loadDashboard(id) {
  const user = await getUser(id);

  const [orders, recommendations] = await Promise.all([
    getOrders(user.id),
    getRecommendations(user.id).catch((error) => {
      console.warn("Recommendations unavailable:", error);
      return [];
    }),
  ]);

  return { user, orders, recommendations };
}

Here, the recommendations branch becomes a fulfilled promise containing [] if it rejects. An orders rejection still rejects the combined operation. This is the same scoped-recovery decision as the earlier try/catch, expressed where independent operations are composed.

Promise.all does not cancel outstanding calls when one fails. That matters if you plan to close a resource or assume no more updates can happen immediately after its rejection. Choose a different completion strategy when you truly must wait for every operation to finish; do not interpret “the combined promise rejected” as “all its inputs stopped.”


Key takeaways

Promise composition depends on which promise you return: .then() follows a promise returned by its handler, and an async function gives its caller a promise even when its code looks synchronous. An awaited rejection throws inside the async function; an uncaught error rejects its returned promise. A catch that returns a value recovers, while a catch that throws preserves failure.

Use sequential awaits when one result supplies another operation’s input. Once inputs are available, use Promise.all for independent required work—and place fallback handling only around work that is genuinely optional. Next, we’ll move from JavaScript runtime behavior to TypeScript’s control-flow checks and discriminated unions, where you will make different possible results explicit in types.

Can't find a good explanation? Sign up and we'll make it for you

Sign up