Lesson 22 of 28
Promises in Depth
What sits under async and await — three states, why every then returns a new promise, fail-fast Promise.all versus allSettled, and the loop that costs you seconds.
You have used await and it worked. Then a .then chain hands the next step
undefined, or a page takes six seconds because ten independent requests
queued up behind each other. Both are promise mechanics, and await hides them
rather than removing them.
Three states and one result
A promise is an ordinary object that stands for a value arriving later. It is
pending until it settles, then either fulfilled with a value or rejected
with a reason. It settles once. After that the result is fixed, and any handler
you attach later gets that same result immediately.
That last point surprises people: attaching .then does not start anything.
The work began when the promise was created. .then only asks to be told.
Creating one, and when not to
new Promise takes a function receiving resolve and reject. Call one of
them when the work finishes.
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}The legitimate reason to write this is wrapping an older callback-based API so
the rest of your code can use await:
function readSetting(key) {
return new Promise((resolve, reject) => {
storage.get(key, (error, value) => {
if (error) reject(error);
else resolve(value);
});
});
}What you should not do is wrap something that is already a promise:
// antipattern
function loadUser(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then((response) => resolve(response.json()))
.catch((error) => reject(error));
});
}
// just this
function loadUser(id) {
return fetch(`/api/users/${id}`).then((response) => response.json());
}The wrapper adds nothing and quietly loses errors thrown outside the then
callbacks. If you already have a promise, return it.
then, catch, finally
.then takes a callback for the fulfilled value. .catch handles a rejection.
.finally runs either way and receives nothing — it is for cleanup like
hiding a spinner.
The key fact is that every .then returns a new promise, resolved with
whatever your callback returned. That is the entire mechanism behind chaining.
fetch("/api/orders/42")
.then((response) => response.json()) // returns a promise
.then((order) => order.customer.id) // returns a number
.then((id) => fetch(`/api/users/${id}`))
.then((response) => response.json())
.then((user) => console.log(user.name))
.catch((error) => console.error("failed:", error.message))
.finally(() => hideSpinner());When a callback returns a promise, the chain waits for it and passes on its
resolved value rather than the promise itself. That flattening is why the
fetch on line four works.
And it is why forgetting return breaks everything:
loadOrder(42)
.then((order) => {
loadCustomer(order.customerId); // no return
})
.then((customer) => console.log(customer.name));
// TypeError: Cannot read properties of undefinedThe second .then runs immediately with undefined, because the first
callback returned nothing. A concise arrow — (order) => loadCustomer(...) —
returns implicitly and avoids the trap.
How errors travel
A rejection skips every .then below it until it reaches a .catch. One
.catch at the end therefore covers the whole chain. A throw inside any
callback behaves the same way as a rejection.
loadOrder(42)
.then((order) => {
if (!order.paid) throw new Error("Order is unpaid");
return order;
})
.then((order) => ship(order))
.catch((error) => console.error(error.message)); // catches either failurePut a .catch in the middle and the chain recovers: everything after it
continues with whatever the catch callback returned. That is useful for
fallbacks and confusing when accidental.
A promise that rejects with no handler anywhere becomes an unhandled rejection.
In the browser it logs a warning you will miss; in Node it crashes the process
by default. Every chain needs a terminal .catch, or an await inside a
try.
There is a second form, .then(onFulfilled, onRejected), and it is not the
same as .then(...).catch(...). The two-argument version cannot see an error
thrown by its own success callback, because that handler is already running.
The chained .catch can. Prefer .catch unless you specifically want to
handle the previous step's failure while ignoring this one's.
async and await are the same machine
async makes a function return a promise no matter what you return. await
pauses that function until the promise settles, then produces its value — or
throws its rejection reason, so ordinary try/catch works.
async function loadUserForOrder(id) {
try {
const order = await loadOrder(id);
if (!order.paid) throw new Error("Order is unpaid");
return await loadCustomer(order.customerId);
} catch (error) {
console.error("failed:", error.message);
return null;
} finally {
hideSpinner();
}
}Only the async function pauses. The rest of the page keeps running. And because
the function returns a promise, the caller still has to await it — an async
function called without await runs, but you get a promise and any rejection
goes unhandled.
Try it yourself
The four combinators
Each has one situation it is for.
Promise.all(list)— you need every result and any failure means the whole operation failed. It rejects the moment one rejects, and the rest keep running in the background with their results discarded.Promise.allSettled(list)— you want to know how each one went. It never rejects; you get{ status, value }or{ status, reason }per entry.Promise.race(list)— first to settle wins, success or failure. Mostly used to add a timeout.Promise.any(list)— first to succeed wins. Rejects only if all of them do. Use it for mirrors or fallbacks.
const [user, orders] = await Promise.all([loadUser(id), loadOrders(id)]);
const results = await Promise.allSettled(files.map(upload));
const failed = results.filter((result) => result.status === "rejected");
const data = await Promise.race([
fetch(url),
wait(5000).then(() => Promise.reject(new Error("Timed out"))),
]);The loop that costs you seconds
This is the most common real performance mistake in JavaScript.
const users = [];
for (const id of ids) {
users.push(await loadUser(id)); // each waits for the last
}
// 10 ids at 300ms each = about 3 secondsIf the requests do not depend on each other, start them all first:
const users = await Promise.all(ids.map((id) => loadUser(id)));
// about 300msids.map calls loadUser immediately for every id, so all ten are in flight
before Promise.all waits. Keep the sequential version only when each step
genuinely needs the previous result, or when you are deliberately rate
limiting.
Cancelling with AbortController
A promise has no cancel method. To stop a request in progress you pass a signal.
const controller = new AbortController();
const promise = fetch("/api/search?q=closures", {
signal: controller.signal,
});
controller.abort(); // the fetch rejects with an AbortErrortry {
const response = await fetch(url, { signal: controller.signal });
} catch (error) {
if (error.name === "AbortError") return; // expected, not a failure
throw error;
}This is how you drop stale results from a search box: keep the controller for
the last request, abort it when a new keystroke arrives. AbortSignal.timeout(5000)
gives you a signal that aborts itself, which is cleaner than racing a timer.
What to remember
- A promise settles once;
.thenonly subscribes, it does not start the work. - Every
.thenreturns a new promise — always return from the callback, and usenew Promiseonly to wrap callback APIs. - A rejection falls to the next
.catch; a chain with none is an unhandled rejection. awaitin a loop is sequential. UsemapplusPromise.allfor independent work, andAbortControllerto stop what you no longer need.
Check yourself
4 questions · pass 3/4 to unlock The Event Loop
1.A
.thencallback runscount + 1without returning it, and the next.thenlogs its argument. What is logged?2.Five uploads run together and you need to report exactly which ones failed. Which combinator fits?
3.What does
.then()return?4.A
for...ofloop awaitsfetchUser(id)for ten ids. What is the timing?
4 left to answer