Overview
Most developers learn Promises by copying patterns. .then().catch(), Promise.all, and if they're lucky, they never think about it again. That works until it doesn't — until a promise resolves too early, an error gets swallowed, or a loop fires off a hundred requests when the intent was to run them one at a time.
This is the mental model that makes all of that obvious.
A promise is a state machine
Three states, and two transitions:
| State | Meaning |
|---|---|
| pending | Still running |
| fulfilled | Completed with a value |
| rejected | Failed with a reason |
A promise starts pending. It becomes fulfilled or rejected exactly once, and that transition is permanent. Calling .then() on an already-fulfilled promise schedules the callback to run — it doesn't re-run the original operation. This is why a promise is safe to pass around and attach multiple handlers to.
.then() returns a new promise
The single most important thing to internalize: .then() does not mutate the promise, it returns a new one. Chaining works because each .then() resolves to whatever the previous callback returned.
const result = fetch("/api/user")
.then(res => res.json()) // returns a promise
.then(user => user.name) // returns a string
.then(name => name.toUpperCase()); // returns a string
Because res.json() returns a promise, the next .then() waits for it. If a callback returns a plain value, the next .then() receives it immediately. If it returns a promise, the next .then() waits.
This is what makes async chaining work without nesting:
// Bad — nesting
fetch("/api/user").then(res => {
res.json().then(user => {
fetch(`/api/posts/${user.id}`).then(posts => {
render(posts);
});
});
});
// Good — flat
fetch("/api/user")
.then(res => res.json())
.then(user => fetch(`/api/posts/${user.id}`))
.then(res => res.json())
.then(posts => render(posts));
Errors skip ahead
When a promise rejects, every subsequent .then() is skipped until a .catch() is found:
fetch("/api/user")
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(user => { /* skipped if the throw above happens */ })
.then(user => { /* also skipped */ })
.catch(err => console.error(err)); // catches it
This is a feature, not a bug. It's the async equivalent of try/catch, without the nesting. It also means .catch() placed at the end catches errors from anywhere in the chain above it — which is usually what you want, and occasionally what you don't.
A .catch() returns a new promise. If it returns a value, the chain continues as fulfilled. If it throws, the chain continues as rejected.
fetch("/api/user")
.catch(() => ({ name: "unknown" })) // recover from failure
.then(user => render(user)); // runs with the fallback
The four combinators
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all | All fulfilled | Any rejects (immediately) |
Promise.allSettled | All settled, regardless of outcome | Never |
Promise.race | First one settles | First one rejects |
Promise.any | First one fulfills | All reject |
Promise.all is the one people reach for and the one that causes the most surprise. If three of five requests succeed and one fails, the whole promise rejects — you never see the successes. When you want to know the outcome of every request individually, use allSettled:
const results = await Promise.allSettled(urls.map(u => fetch(u)));
results.forEach(r => {
if (r.status === "fulfilled") console.log("ok", r.value);
else console.error("failed", r.reason);
});
Promise.race is the classic timeout pattern, though AbortSignal.timeout() is now the better approach. Promise.any is for "try several mirrors and use whichever responds first" — it resolves on the first success rather than the first settle.
The executor runs synchronously
This one surprises people who haven't thought about it. The function passed to the Promise constructor runs immediately, before the constructor returns.
console.log("1");
const p = new Promise(resolve => {
console.log("2");
resolve();
});
console.log("3");
p.then(() => console.log("4"));
// Output: 1, 2, 3, 4
The promise body runs in line with the rest of the code. Only the .then() callbacks are deferred to the microtask queue. This is why wrapping a synchronous throw in a promise doesn't help you — the throw happens synchronously, before you have a promise to catch on.
Microtasks run before macrotasks
Promise callbacks go on the microtask queue, which drains completely before the next task on the macrotask queue (timers, I/O callbacks).
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
// Output: sync, promise, timeout
Even a zero-delay timer runs after all pending microtasks. This is why a promise chain that resolves immediately still logs after synchronous code. And it's why a promise that never resolves will not just "wait" — it blocks every pending .then() in the queue behind it.
The async/await translation
Everything above translates directly:
| Promise | async/await |
|---|---|
.then(v => ...) | const v = await ... |
.catch(e => ...) | try/catch |
.finally(() => ...) | finally { ... } |
Returning a promise from .then | Awaiting it directly |
An async function always returns a promise, even if its body returns a primitive. So async function f() { return 5; } is exactly equivalent to function f() { return Promise.resolve(5); }.
Mistakes that keep happening
| Mistake | What actually happens |
|---|---|
arr.forEach(async item => { await ... }) | Does not wait. Use for...of or Promise.all(arr.map(...)). |
new Promise(async (resolve) => {...}) | Errors inside the async executor are lost. Never use async in an executor. |
No .catch() or try/catch | UnhandledPromiseRejection. In Node, this can terminate the process. |
Returning a value without return inside .then | The chain resolves to undefined. |
Using Promise.all for sequential dependencies | The dependency doesn't exist yet — the promise resolves with garbage. |
The rule that prevents most bugs
If you take nothing else from this: whenever you write .then(), ask what the callback returns. If the answer is "a promise I didn't await," you've found your bug before the debugger does.
