A Promise is a state machine with exactly three states and one rule: once settled, it never changes again. Every combinator (all, race, allSettled, any) and every chaining gotcha follows directly from that one rule.
promisesasyncthencatchmicrotasksfundamentals
Knowledge Check
5 questions · pass at 70%
0/5
easymcq
1. Which of the three promise states can a promise return to, once left?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
Promise states: pending → fulfilled (ONE-WAY, permanent once settled)
→ rejected
new Promise((resolve, reject) => {...})
→ executor runs SYNCHRONOUSLY, immediately
.then(onFulfilled, onRejected) — always returns a NEW promise
.catch(onRejected) — sugar for .then(undefined, onRejected)
.finally(fn) — runs regardless of outcome, doesn't see the value
Returning a promise FROM a .then() callback auto-flattens the chain
(waits for it, unwraps its value) — promises never nest.
Combinators:
Promise.all([...]) → ALL fulfill, or reject on FIRST rejection
Promise.allSettled([...]) → NEVER rejects, per-promise status/value/reason
Promise.race([...]) → settles as whichever promise settles FIRST
Promise.any([...]) → FIRST fulfillment, or AggregateError if ALL reject
Rules:
ALWAYS return inside .then() callbacks that chain further work
ALWAYS end a chain with .catch() (or wrap awaits in try/catch)
Don't wrap an existing promise in `new Promise(...)` — just return it
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 ~1s
The 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#
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().
Chained .then() callbacks all drain before the macrotask queue
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 input
Promise.resolve(1) .then((value) => { console.log(value); return value + 1; }) .then((value) => { console.log(value); throw new Error("oops");
Solution
12caught: oops
The 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 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.
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.
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.
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 posts
Without 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() succeeded
Promise.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.
Always end a promise chain with .catch(), or wrap await calls in try/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-return mistake above is common enough to actively guard against.
Reach for Promise.allSettled by default when coordinating independent operations where partial failure is acceptable; reserve Promise.all for cases where any single failure genuinely should abort the whole operation.
Promise.all runs all its promises concurrently (they're all already "in flight" the moment you call the function that creates them, before Promise.all even 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.
The FIRST promise to fulfill
Only if ALL reject (an AggregateError)
})
.then((value) => {
console.log("never runs:", value);
})
.catch((err) => {
console.log("caught:", err.message);
});
forEach
((
p
,
index
)
=>
{
Promise.resolve(p) // handles non-promise values passed in too
.then((value) => {
results[index] = value; // preserve ORDER, even though completion order varies
remaining -= 1;
if (remaining === 0) resolve(results);
})
.catch(reject); // ANY single rejection rejects the whole thing, immediately
});
});
}
myPromiseAll([
new Promise((r) => setTimeout(() => r("slow"), 100)),
Promise.resolve("fast"),
]).then((results) => console.log(results)); // ["slow", "fast"] — ORIGINAL order preserved
Don't wrap an already-promise-returning function in new Promise(...)