Concept
The beginner framing: a Promise is an object representing a value that doesn't exist yet, but will (or will fail trying), you attach .then() to say "when it's ready, do this," and .catch() to say "if it fails, do this instead."
The precise mental model: a Promise is a state machine with exactly three states:
- pending, the initial state, neither fulfilled nor rejected.
- fulfilled, the operation succeeded; the promise has a value.
- rejected, the operation failed; the promise has a reason (usually an Error).
The one rule that explains almost everything else about promises: once a promise transitions out of pending, it is settled forever, it can never change state again, and its value/reason is locked in. This is why calling .then() on an already-resolved promise still works correctly (it fires with the stored value) and why a promise can't "resolve twice."
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("done!"), 1000);
});
promise.then((value) => console.log(value)); // "done!", after ~1sThe executor function (the (resolve, reject) => {...} you pass to new Promise()) runs synchronously, immediately, the moment the promise is constructed, it's only the eventual resolve/reject call that's typically deferred.
console.log("1");
new Promise((resolve) => {
console.log("2"); // runs SYNCHRONOUSLY, right here, right now
resolve("value");
});
console.log("3");
// Output: 1, 2, 3, the executor itself is not async.then() always returns a NEW promise, this is what makes chaining work
fetchUser(1)
.then((user) => user.name) // returns a new promise, resolved with user.name
.then((name) => name.toUpperCase()) // chains off THAT promise
.then((upper) => console.log(upper));Whatever you return from inside a .then() callback becomes the resolved value of the next promise in the chain, including returning another promise, which gets automatically "flattened" (chained promises never nest).
fetchUser(1).then((user) => {
return fetchPosts(user.id); // returning a PROMISE here...
}).then((posts) => {
console.log(posts); // ...means this .then() waits for it and receives ITS resolved value
});.catch() catches a rejection from ANY earlier step in the chain
step1()
.then(step2)
.then(step3)
.catch((err) => console.error("failed at whichever step threw:", err));A single .catch() at the end of a chain catches a rejection no matter which .then() it came from, the moment any step rejects, every subsequent .then() is skipped and control jumps straight to the nearest .catch().
console.log('start');setTimeout(() => console.log('timeout'), 0);Promise.resolve().then(() => console.log('then 1')).then(() => console.log('then 2')).then(() => console.log('then 3'));console.log('end');
console.log('start') runs first, synchronously.
The four combinators
| Combinator | Resolves when | Rejects when |
|---|---|---|
Promise.all([...]) | ALL promises fulfill (returns array of values) | ANY one rejects (immediately, with that reason) |
Promise.allSettled([...]) | ALL promises settle (fulfilled OR rejected) | Never, always resolves, with a status per promise |
Promise.race([...]) | The FIRST promise to settle, fulfilled | The FIRST promise to settle, if it's a rejection |
Promise.any([...]) |
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(999), // this one 404s
]);
// [{ status: 'fulfilled', value: {...} }, { status: 'rejected', reason: Error }]
// allSettled NEVER short-circuits, you always get a result for every inputTry It
Predict the output before running.
Promise.resolve(1)
.then((value) => {
console.log(value);
return value + 1;
})
.then((value) => {
console.log(value);
throw new Error("oops");
})
.then((value) => {
console.log("never runs:", value);
})
.catch((err) => {
console.log
Solution
1
2
caught: oopsThe third .then() is skipped entirely, once a rejection occurs (the thrown error), every subsequent .then() is bypassed until the chain reaches a .catch() (or a .then() with a second, rejection-handling argument). This is the promise-chain equivalent of a try block: a throw jumps straight past remaining code to the nearest handler.
Implement It Yourself
Implement a simplified myPromiseAll to understand exactly how the combinator coordinates multiple promises:
function myPromiseAll(promises) {
return new Promise((resolve, reject) => {
const results = new Array(promises.length);
let remaining = promises.length;
if (remaining === 0) return resolve([]);
promises.forEach((p, index) => {
Promise.resolve(p) // handles non-promise values passed in too
.then((value) => {
results[index] =
The results[index] = value line is the key insight: even though "fast" resolves before "slow," the output array preserves the input order, not the completion order, exactly matching real Promise.all behavior.
In React
Suspense-based data fetching (React 18+, and frameworks like Next.js built on it) works by having a component "throw" a promise during render, React catches it, shows the nearest <Suspense> fallback, and re-renders the component once the promise settles. This is a direct application of the "a promise is a state machine you can inspect" model: React's internals are essentially doing promise.then(() => rerender()) with extra bookkeeping. Understanding promise states here demystifies what would otherwise look like framework magic, Suspense doesn't invent a new async primitive, it builds a rendering protocol on top of the exact three-state promise you already know.
Common Mistakes
1. Forgetting to return inside a .then()
function loadUser(id) {
return fetchUser(id).then((user) => {
fetchPosts(user.id); // ❌ missing `return`, this promise is silently dropped
});
}
loadUser(1).then((result) => console.log(result)); // undefined, NOT the postsWithout return, the outer chain doesn't wait for fetchPosts at all, it resolves with undefined (the implicit return value) the moment the .then() callback finishes, regardless of whether the inner promise has settled yet.
2. Creating an unnecessary "promise wrapping a promise" anti-pattern
function getData() {
return new Promise((resolve, reject) => {
fetchData().then(resolve).catch(reject); // pointless, fetchData() is ALREADY a promise
});
}
// Just: function getData() { return fetchData(); }If you already have a promise, return it directly, wrapping it in new Promise(...) just to re-resolve/re-reject it adds a layer of indirection (and an easy place to introduce a bug, like forgetting the .catch(reject)) for no benefit.
3. Using Promise.all when you actually want allSettled
const results = await Promise.all([fetchA(), fetchB(), fetchC()]);
// if fetchB() rejects, the WHOLE THING rejects immediately, you never
// even see whether fetchA() or fetchC() succeededPromise.all short-circuits on the first rejection. If you need to know the outcome of every operation regardless of individual failures (e.g., uploading several independent files, where one failing shouldn't hide the others' results), Promise.allSettled is almost always the right choice instead.
Best Practices
- Always end a promise chain with
.catch(), or wrapawaitcalls intry/catch, an unhandled rejection is a silent failure mode that's easy to miss in development and painful to debug in production. - Return promises from
.then()callbacks explicitly rather than relying on implicit returns from arrow functions with braces, the missing-returnmistake above is common enough to actively guard against. - Reach for
Promise.allSettledby default when coordinating independent operations where partial failure is acceptable; reservePromise.allfor cases where any single failure genuinely should abort the whole operation. - , return it directly.
Performance Tips
Promise.allruns all its promises concurrently (they're all already "in flight" the moment you call the function that creates them, beforePromise.alleven sees them), the combinator itself doesn't slow anything down; it just coordinates already-parallel work.- Chaining
.then()calls has negligible per-step overhead, each one is just a microtask, extremely cheap. Don't avoid chaining for "performance" reasons; readability should drive the decision. - Creating a promise that's never awaited or
.then()-ed still runs its executor, but if it rejects and nothing ever handles that rejection, you get an "unhandled promise rejection" warning (and in Node, by default, this can crash the process), always attach a handler, even if it's just logging.
