Promises & async/await: chaining, errors and concurrency
- Explain promise states and chaining: a promise settles once, and
then/catchreturn new promises shaped by what their callbacks return or throw - Place a
catch(or atryaroundawait) where it really handles the failure, and spot a swallowed error, a missingreturn/await, orreturnwithoutawaitinsidetry - Choose sequential or parallel
await, andPromise.all,allSettled,raceorany, from the dependencies and failure rules of the work - Predict when a rejection goes unhandled, and what browsers and Node 15+ do then
Read
A promise settles once, and then returns a new promise
FlagA 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 value | fulfils with that value |
| returns a promise | follows it, settling the same way when it does |
| throws | rejects 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.
Sequential vs parallel await, and the dependency that decides
FlagAn 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.
One missing return or await, four different bugs
FlagMost 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?
async function getCount() { return 3; } — what is getCount() + 1?
const user = await fetchUser(id).catch((err) => console.error(err)); render(user.name); — fetchUser rejects with "user service down". What reaches the error tracker?
async function getSettings() { try { return fetchSettings(); } catch { return DEFAULTS; } } — when fetchSettings rejects, the defaults are never returned. Why?
You need user, then orders and alerts. Both need user.accountId, but not each other. Which is the fastest correct shape?
You fetch the same file from three mirrors. Use whichever succeeds first, and fail only if all three fail. Which combinator?
FlagIn 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?
Fill in the blank
Make the chain wait for the save, and carry a failed save to the next catch.
loadDraft() .then((draft) => { saveDraft(draft); }) .then(() => showToast("Saved"));
Make the fallback catch a rejected fetch.
Flagasync function getSettings() { try { return fetchSettings(); } catch { return DEFAULT_SETTINGS; } }
Fetch every user at once, and get the results in the same order as ids.
const users = await (ids.map((id) => fetchUser(id)));
Run every request, then pick out the ones that failed.
Flagconst 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.)
Flagprocess.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");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);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);
}// 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);Challenge
Fix the dashboard loader: real errors, parallel panels, partial failure
FlagloadDashboard(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,
loadDashboardrejects 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