Async JavaScript & Timing
Async JavaScript & Timing

Promises & async/await: chaining, errors and concurrency

Full · ~40 min
After this unit you can
  • Explain promise states and chaining: a promise settles once, and then/catch return new promises shaped by what their callbacks return or throw
  • Place a catch (or a try around await) where it really handles the failure, and spot a swallowed error, a missing return/await, or return without await inside try
  • Choose sequential or parallel await, and Promise.all, allSettled, race or any, from the dependencies and failure rules of the work
  • Predict when a rejection goes unhandled, and what browsers and Node 15+ do then

Read

Simple
1 / 2

A promise settles once, and then returns a new promise

Flag

A promise is a placeholder for a result that isn't ready yet. It starts pending and settles exactly once, as fulfilled with a value or rejected with a reason (usually an Error). After that it never changes: further resolve or reject calls are ignored.

const p = new Promise((resolve, reject) => {
  resolve("first");
  resolve("second"); // ignored
  reject(new Error("too late")); // ignored
});
p.then((v) => console.log(v)); // first

The function you pass to new Promise (the executor) runs immediately, synchronously. Only the .then callbacks wait, and they always run later as microtasks, even on an already-settled promise (see The event loop).

then returns a new promise, and what your callback does decides how that new promise settles:

The callback…The promise then returned…
returns a valuefulfils with that value
returns a promisefollows it, settling the same way when it does
throwsrejects with what was thrown

That table is the whole of chaining:

fetchUser(1)                             // Promise<User>
  .then((user) => user.teamId)           // Promise<number>
  .then((teamId) => fetchTeam(teamId))   // returns a promise, so the chain waits for it
  .then((team) => console.log(team.name));

An async function always returns a promise. return x fulfils it with x, throw e rejects it with e, and a returned promise is followed. Even async function f() { return 1; } returns a promise of 1, not 1, so callers need await f() or f().then(…).

await p is the readable form of .then. It pauses the async function until p settles, then gives you the value, or throws the rejection reason right there, where a normal try/catch can handle it.

Medium
1 / 2

Sequential vs parallel await, and the dependency that decides

Flag

An await pauses only its own async function, but it pauses it completely. An await inside a loop means each request starts only after the previous one has finished:

// ❌ Sequential: three 100 ms requests take ~300 ms
const users = [];
for (const id of [1, 2, 3]) {
  users.push(await fetchUser(id));
}

Requests start the moment you call the function, not when you await it. So start them all, then wait for all of them:

// ✅ Parallel: ~100 ms, the slowest single request
const users = await Promise.all([1, 2, 3].map((id) => fetchUser(id)));

Promise.all keeps the input order (users[0] is user 1, whichever finished first) and rejects as soon as any request rejects (the next reading covers the alternatives).

The rule: a step waits only for what it actually needs. Real loaders mix both shapes. Here the panels need the user's accountId, but not each other:

async function loadDashboard(userId) {
  const user = await getUser(userId);              // must come first: the others need it
  const [orders, alerts] = await Promise.all([     // independent: start together
    getOrders(user.accountId),
    getAlerts(user.accountId),
  ]);
  return { user, orders, alerts };
}

Sequential is still correct when steps really depend on each other, when order matters (applying migrations), or when you must not hit a server with N requests at once. For the last case, use a small concurrency limit rather than all-or-one-at-a-time.

forEach doesn't wait. It calls your async callback, ignores the promise it returns, and moves on:

// ❌ "Done" logs before any user has loaded, and failures become unhandled rejections
ids.forEach(async (id) => {
  users.push(await fetchUser(id));
});
console.log("done", users.length); // done 0

Use for…of with await (sequential) or await Promise.all(ids.map(…)) (parallel). Don't pass an async callback to forEach (it returns undefined, so there's nothing to wait for) or to filter (it tests the promise, which is always truthy, so it keeps every element). An async reduce runs in order only if each step awaits the accumulator first.

Complex
1 / 2

One missing return or await, four different bugs

Flag

Most promise bugs in review come down to a single missing keyword. The code still runs, it just stops waiting, or stops catching.

1. A .then callback that doesn't return the inner promise. The chain no longer waits for it, so the next step runs early and its errors go nowhere. MDN calls this a floating promise:

// ❌ The next .then runs before the save finishes; a failed save is unhandled
loadDraft()
  .then((draft) => { saveDraft(draft); })   // braces, no return
  .then(() => showToast("Saved"));

// ✅ Return it, and the chain waits (and carries its error to the next catch)
loadDraft()
  .then((draft) => saveDraft(draft))
  .then(() => showToast("Saved"));

2. A call without await in an async function. Same bug, newer syntax:

async function onSubmit() {
  saveDraft(form);  // ❌ not awaited: navigation wins the race, and a failure is unhandled
  router.push("/drafts");
}

3. return promise inside try instead of return await promise. The try only covers what happens while the function is running. Return the promise un-awaited and the function has already left the try by the time it rejects:

async function getSettings() {
  try {
    return fetchSettings();        // ❌ a rejection skips the catch below
  } catch {
    return DEFAULT_SETTINGS;
  }
}
// with `return await fetchSettings();` the fallback works

Outside a try, return await and return behave the same, so the rule is simply: inside try, await before you return.

4. Using a promise as if it were its value. A promise object is truthy, so a forgotten await in a condition always passes:

if (isAllowed(user)) deleteProject(id);        // ❌ isAllowed is async: always true
if (await isAllowed(user)) deleteProject(id);  // ✅

TypeScript catches only part of this. With strictNullChecks (part of strict), a promise used directly as an if, && or ? : condition is error TS2801:

This condition will always return true since this 'Promise<boolean>' is always defined.

It doesn't flag if (!isAllowed(user)), an async callback passed to filter or forEach, or cases 1 to 3. typescript-eslint's no-floating-promises (cases 1 and 2) and no-misused-promises (conditions, plus async callbacks where a void function is expected, such as forEach or an event listener) cover most of the rest. Both need type information and are in its recommended-type-checked config.

Quiz

What does p.then(callback) return?

Flag

async function getCount() { return 3; } — what is getCount() + 1?

Flag

const user = await fetchUser(id).catch((err) => console.error(err)); render(user.name); — fetchUser rejects with "user service down". What reaches the error tracker?

Flag

async function getSettings() { try { return fetchSettings(); } catch { return DEFAULTS; } } — when fetchSettings rejects, the defaults are never returned. Why?

Flag

You need user, then orders and alerts. Both need user.accountId, but not each other. Which is the fastest correct shape?

Flag

You fetch the same file from three mirrors. Use whichever succeeds first, and fail only if all three fail. Which combinator?

Flag

In a Node 22 request handler: const a = slowQuery(); const b = fastFailingQuery(); const x = await a; try { await b; } catch { /* fallback */ }. b rejects while the handler is still waiting on a. What happens?

Flag

Fill in the blank

Make the chain wait for the save, and carry a failed save to the next catch.

Flag
loadDraft()
  .then((draft) => {  saveDraft(draft); })
  .then(() => showToast("Saved"));

Make the fallback catch a rejected fetch.

Flag
async function getSettings() {
  try {
    return  fetchSettings();
  } catch {
    return DEFAULT_SETTINGS;
  }
}

Fetch every user at once, and get the results in the same order as ids.

Flag
const users = await (ids.map((id) => fetchUser(id)));

Run every request, then pick out the ones that failed.

Flag
const results = await Promise.(requests);
const failures = results.filter((r) => r.status === "");

In Node, report unhandled rejections to monitoring. (Remember that adding the listener also replaces Node's default crash.)

Flag
process.on("", (reason) => reportToMonitoring(reason));

Predict the output

const p = new Promise((resolve, reject) => {
  console.log("executor");
  resolve(1);
  reject(new Error("nope"));
  resolve(2);
});

p.then((v) => {
  console.log("then", v);
  return v * 10;
}).then((v) => console.log("next", v));

console.log("sync end");
Flag
const fetchUser = (id) => new Promise((resolve) => setTimeout(() => resolve({ id }), 50));

const users = [];
[1, 2, 3].forEach(async (id) => {
  users.push(await fetchUser(id));
});
console.log("after forEach:", users.length);

setTimeout(() => console.log("100 ms later:", users.length), 100);
Flag
const delay = (ms, label, fail) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      console.log("finished", label);
      fail ? reject(new Error(label + " failed")) : resolve(label);
    }, ms),
  );

try {
  await Promise.all([delay(50, "a"), delay(10, "b", true), delay(30, "c")]);
} catch (err) {
  console.log("caught", err.message);
}
Flag
// Run with Node 15 or later
Promise.reject(new Error("HTTP 500"))
  .then((data) => console.log("data", data))
  .catch((err) => {
    if (err.name !== "AbortError") throw err;
  });

setTimeout(() => console.log("still running"), 100);
Flag

Challenge

Fix the dashboard loader: real errors, parallel panels, partial failure

Flag

loadDashboard(userId) builds a dashboard: the signed-in user, then three panels (orders, notifications, recommendations). The panels are keyed by the user's accountId, which only getUser returns, so the user has to come first. The panels don't depend on each other.

api.ts is the backend (read-only), and config.ts holds the request latency the tests use. The loader works on a good day. Its three bugs show up on a bad one:

  • The user service is down. The loader swallows the error and "succeeds" with no user. The screen breaks somewhere else, and the real cause never reaches your error tracker.
  • It's slow. The panels load one after another, so the page takes the sum of three requests instead of the slowest one.
  • One panel's service is down. A flaky recommendations service blanks the whole dashboard, when it should show the other panels and an error box in its place.

Your task

Edit loader.ts so the tests pass:

  • A dashboard loads the user and all three panels
  • If the user can't be loaded, loadDashboard rejects with the user service's own error, and no panel is requested
  • The three panels are requested at the same time, not one after another
  • A panel that fails becomes { status: "error", message } while the user and the other panels still load
  • A panel that fails while the others are still loading is already being handled: no unhandled rejection in between

Loading the in-browser editor…

Further reading