Promises are the foundation everything async in JavaScript is built on —
fetch, async/await, dynamic import(). Most of us use them daily
while carrying a slightly fuzzy model of how they behave, and the fuzz
shows up as unhandled rejections, accidental sequential code, and .then
chains that silently swallow values. This post nails down the actual
rules. (For when promise callbacks run — microtasks and the event loop —
see the event loop post; this one is about
what they do.)
Three states, one transition
A promise settles exactly once — fulfilled or rejected — and never changes state again. Extra resolve/reject calls are silently ignored.
A promise is a state machine with exactly one move: from pending to either fulfilled (with a value) or rejected (with a reason). That transition happens once. Settle a promise twice and the second call is silently ignored:
const p = new Promise((resolve, reject) => {
resolve("first");
resolve("second"); // ignored
reject("nope"); // also ignored
});
This one-shot guarantee is the entire value proposition: a promise is a container you can hand to five different consumers, attach callbacks before or after it settles, and everyone observes the same immutable outcome. Compare that to a raw callback, which you just have to trust gets called exactly once.
The chaining rules
.then doesn't just attach a callback — it returns a new promise,
whose fate is decided by what your callback does. There are only three
cases, and they're worth memorizing:
- Return a plain value → the new promise fulfills with that value.
- Return a promise → the new promise adopts it: waits for it and mirrors its outcome. This is why chains flatten instead of nesting.
- Throw → the new promise rejects with the thrown error.
fetch("/api/user")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // rule 3
return res.json(); // rule 2 (a promise)
})
.then((user) => user.name) // rule 1 (a value)
.then((name) => console.log(name))
.catch((err) => console.error("somewhere above failed:", err));
Two practical corollaries:
- Forgetting
returnbreaks the chain. If a callback runssavePromisebut doesn't return it, the next.thenfires immediately withundefined, and errors from the orphaned promise escape the chain's.catchentirely. This is probably the most common promise bug in existence. - Nesting is a smell. Any time you write
.then(x => f(x).then(...))you almost certainly wanted to returnf(x)and chain at the top level.
How errors travel
A rejection skips forward past every fulfillment handler until it
meets a rejection handler — exactly like a thrown exception unwinding
past stack frames until a catch. And .catch(fn) is literally
.then(undefined, fn).
One subtlety worth knowing: .then(onFulfilled, onRejected) and
.then(onFulfilled).catch(onRejected) are not the same. The
two-argument form can't catch errors thrown by its own onFulfilled;
the chained .catch can, because it sits downstream of it.
After a .catch, the chain is recovered — whatever the catch handler
returns becomes a normal fulfillment for the next link. That's a feature
(fallback values) and a footgun (accidentally continuing a pipeline that
should have stopped). Re-throw if you only meant to log:
.catch((err) => {
reportError(err);
throw err; // keep the chain rejected
})
If nothing in the chain ever handles a rejection, the runtime raises
unhandledrejection (browsers) or, in Node, crashes the process by
default — promises make it loud when you drop errors on the floor.
The combinators
Four static methods coordinate multiple promises, differing along two axes: do you need all results or just one, and what should a failure do?
| Combinator | Resolves with | Rejects when | Use for |
|---|---|---|---|
| Promise.all | Array of all values (order preserved) | Any one rejects (fail fast) | Parallel work where every piece is required |
| Promise.allSettled | Array of | Never | Batch jobs where partial success is fine |
| Promise.race | First to settle — fulfilled or rejected | First settler rejected | Timeouts, "whichever responds first" |
| Promise.any | First to fulfill | All reject (AggregateError) | Redundant sources, fastest mirror wins |
The pattern that shows up constantly — independent requests should run
in parallel, and await makes it deceptively easy not to:
// Sequential by accident: ~600ms if each takes 200ms
const user = await fetchUser();
const posts = await fetchPosts();
const tags = await fetchTags();
// Parallel on purpose: ~200ms
const [user2, posts2, tags2] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchTags(),
]);
Each bare await parks the function until that promise settles, so three
independent fetches quietly serialize. The same trap wears a second
costume as await inside a loop — when iterations don't depend on each
other, build an array of promises and Promise.all it.
And race gives you timeouts in four lines:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
(One honest caveat: losing promises aren't cancelled — the slow fetch
keeps running, you just stop waiting for it. Actual cancellation needs
AbortController.)
Odds and ends worth knowing
Promise.resolve(x)is idempotent — ifxis already a promise it returns it as-is, which makes it the standard way to normalize "value-or-promise" inputs.- The executor runs synchronously —
new Promise(fn)callsfnimmediately; only the.thencallbacks are deferred (to the microtask queue). .finallypasses through — it sees no value and returns the chain's outcome unchanged; perfect for hiding spinners regardless of result.asyncfunctions are promise factories —return xfulfills,throwrejects, and callers compose them with the same combinators.
The mental model that ties it together: a promise chain is a pipeline of one-shot containers, each link deciding the next one's fate by value, promise, or throw — with rejections riding an express lane to the nearest catch. Every confusing chain becomes legible once you trace it link by link with those three rules.