async/await made asynchronous code look like ordinary code — so much so that many people write it without understanding the promises underneath. It works — until you hit an await that never resolves, a silently swallowed error, or sequential loading where parallel was needed. Let's break down the mechanism.
A three-state machine
A promise is an object promising a future result. There are three states, and the transition is one-time:
pending— waiting;fulfilled— success with a value;rejected— failure with a reason.
Once it moves to fulfilled or rejected (together — settled) — it's frozen forever: repeated resolve/reject calls are ignored. You subscribe to the result with then(onFulfilled, onRejected), and you can subscribe at any time, even an hour after completion: the callback will still get the result. A promise is not a "process" but a "cell holding a future value".
Promise callbacks run as microtasks — after the current synchronous code but before timers. The "synchronous code → microtasks → setTimeout" order is covered in the article on the event loop.
Chains: then always returns a new promise
The key to reading chains is three rules about what you return from then:
fetch("/api/user")
.then((res) => res.json()) // returned a PROMISE → the chain waits for it
.then((user) => user.name) // returned a VALUE → it flies onward
.then((name) => {
console.log(name); // returned nothing → undefined goes onward
});
- Returned a value — the next
thenreceives it. - Returned a promise — the chain waits for it and takes its result (which is why chains are flat, not nested).
- Threw an exception — the chain switches to rejected.
The classic beginner mistake is to "nest" instead of "return":
// Bad: pyramid of doom, errors of the inner chain are invisible to the outer one
getUser().then((user) => {
getOrders(user.id).then((orders) => { ... });
});
// Good: returned the promise — the chain is flat, errors are caught by one catch
getUser()
.then((user) => getOrders(user.id))
.then((orders) => { ... })
.catch(handle);
Errors: fly down the chain to the nearest catch
reject and a thrown exception behave identically: they fly past every then without an error handler and land in the nearest catch:
fetch("/api")
.then(parse) // skipped on error
.then(render) // skipped
.catch(showError) // ← the error is caught here
.then(cleanup); // and this runs: catch "healed" the chain
Two important details. First: catch returns an ordinary promise — after it the chain is "healthy" again, unless you re-throw from the catch. Second: a promise without a single catch produces an unhandled rejection on failure — an error that surfaces globally in the console and in Node can bring down the process. The rule: every chain you don't return to anyone must have a catch.
A separate grenade is fetch: it does not reject on HTTP errors. 404 and 500 are fulfilled with res.ok === false; fetch rejects only on network failures. The if (!res.ok) throw ... check is written by hand.
async/await: sugar over the same chains
An async function always returns a promise; await is "subscribe and wait" for any promise; try/catch around await is the same .catch:
async function load() {
try {
const res = await fetch("/api/user");
if (!res.ok) throw new Error(res.status);
return await res.json(); // a promise with this value will be returned
} catch (e) {
showError(e);
}
}
The main await pitfall is unnecessary sequencing:
// 6 seconds: the second request waits for the first, though it doesn't depend on it
const user = await getUser(); // 3 sec
const news = await getNews(); // 3 sec
// 3 seconds: start both, wait together
const [user, news] = await Promise.all([getUser(), getNews()]);
await in a for loop is the same trouble from another angle: N requests run one at a time. If the items are independent — Promise.all(items.map(...)).
Combinators: all, allSettled, race
Promise.all— all or nothing: fulfilled with an array of results, but the first reject takes everything down. For "the page makes no sense without all the data".Promise.allSettled— wait for all and get{status, value|reason}for each. For "show what loaded, report the rest".Promise.race— the first one to settle (no matter how). The classic — a request timeout.Promise.any— the first successful one. For requests to mirrors/fallbacks.
In short
- Three states, a one-time transition; then can be called even after completion. Callbacks are microtasks.
- then returns a new promise: a value flies onward, a returned promise is awaited, an exception switches to reject. Return, don't nest.
- An error flies to the nearest catch; after catch the chain is healthy again. A chain without catch = unhandled rejection. fetch doesn't reject on 404/500.
- An async function always returns a promise; try/catch around await = .catch. Independent requests — Promise.all, not sequential awaits.
- all — all or nothing; allSettled — an outcome for each; race — the first of any kind; any — the first successful.
What to read next
- Event loop — where exactly promise callbacks run.
- Asynchrony and the event loop in TypeScript — typing asynchronous code.
- Iterators and generators — the final topic of the series, including for await..of.