16 — Promise Internals: States, Chaining, Composition & Error Propagation
The Promise State Machine
javascript
// ── A Promise is a state machine with exactly 3 states ──
//
// ┌──────────┐ resolve() ┌───────────┐
// │ PENDING │ ────────────────→ │ FULFILLED │ (has a value)
// │ │ └───────────┘
// │ │ ┌───────────┐
// │ │ ──reject()───────→ │ REJECTED │ (has a reason/error)
// └──────────┘ └───────────┘
//
// Transitions are ONE-WAY: once settled (fulfilled or rejected), a Promise can't change state.
// The value (or reason) is immutable after settlement.
// ── Creating a Promise ──
const pending = new Promise((resolve, reject) => {
// The executor function runs SYNCHRONOUSLY when the Promise is created.
// Call resolve(value) to fulfill, reject(error) to reject.
// If neither is called, the Promise stays pending forever.
setTimeout(() => resolve("done"), 1000);
});
// ── A Promise that's already settled (immediate) ──
const fulfilled = Promise.resolve(42); // already fulfilled with 42
const rejected = Promise.reject(new Error("fail")); // already rejected
// ── State transitions are permanent ──
const p = new Promise((resolve, reject) => {
resolve("first"); // fulfilled with "first"
resolve("second"); // IGNORED (already settled — can't change)
reject("error"); // IGNORED (already settled)
});
p.then(v => console.log(v)); // "first" (only the first settlement counts)
Chaining: then, catch, finally
javascript
// ── .then(onFulfilled, onRejected) ──
// Returns a NEW Promise. The return value of the callback becomes the next Promise's value.
Promise.resolve(1)
.then(v => v + 1) // returns 2 → next Promise is fulfilled with 2
.then(v => v * 3) // returns 6
.then(v => console.log(v)); // logs 6
// ── Returning a Promise from .then (unwraps automatically) ──
Promise.resolve(1)
.then(v => fetch(`/api/${v}`)) // returns a Promise → unwrapped (waits for it)
.then(response => response.json()) // returns another Promise → unwrapped
.then(data => console.log(data)); // the resolved data
// ── .catch(onRejected) — shorthand for .then(null, onRejected) ──
Promise.reject(new Error("fail"))
.catch(err => console.log("caught:", err.message)); // "caught: fail"
// ── .finally(onSettled) — runs on both fulfill and reject (no value change) ──
Promise.resolve("data")
.finally(() => console.log("cleanup")) // runs regardless (returns undefined → doesn't change value)
.then(v => console.log("value:", v)); // "value: data" (finally didn't change the value)
// ── Error propagation: rejected Promises skip .then and jump to .catch ──
Promise.resolve()
.then(() => { throw new Error("boom"); }) // throws → Promise rejects
.then(() => console.log("skipped")) // SKIPPED (previous rejected)
.catch(err => console.log("caught:", err.message)) // "caught: boom"
.then(() => console.log("after catch")); // "after catch" (catch handled it → fulfilled)
// ── .then with onRejected (less readable than .catch) ──
Promise.reject("error")
.then(
v => console.log("fulfilled:", v),
e => console.log("rejected:", e) // handles rejection here
);
// Prefer .catch over the second arg to .then for readability.
Anti-Pattern: Nested Promises (Pyramid of Doom)
javascript
// ❌ NAIVE — nesting Promises (callback hell with Promises)
fetch("/api/user/1")
.then(user => {
fetch(`/api/posts?userId=${user.id}`)
.then(posts => {
fetch(`/api/comments?postId=${posts[0].id}`)
.then(comments => {
console.log(comments);
});
});
});
// ✅ CORRECT — chain with return (each .then unwraps the returned Promise)
fetch("/api/user/1")
.then(user => fetch(`/api/posts?userId=${user.id}`))
.then(posts => fetch(`/api/comments?postId=${posts[0].id}`))
.then(comments => console.log(comments))
.catch(err => console.error("error:", err));
// Each .then returns a Promise → the next .then waits for it (flat chain, no nesting).
// ✅ BEST — async/await (syntactic sugar over Promise chains)
async function loadData() {
try {
const user = await fetch("/api/user/1");
const posts = await fetch(`/api/posts?userId=${user.id}`);
const comments = await fetch(`/api/comments?postId=${posts[0].id}`);
console.log(comments);
} catch (err) {
console.error("error:", err);
}
}
Promise Composition: all, race, allSettled, any
javascript
// ── Promise.all: wait for ALL to fulfill (or first rejection) ──
// Returns an array of results in the same order as the input.
const [user, posts] = await Promise.all([
fetch("/api/user").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
]);
// If ANY rejects, the whole Promise.all rejects immediately (others keep running but ignored).
// ── Promise.race: first to settle (fulfill or reject) wins ──
const fastest = await Promise.race([
fetch("/api/fast"),
fetch("/api/slow"),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
// The first Promise to settle (fulfill or reject) determines the result.
// ── Promise.allSettled: wait for ALL, never rejects (returns status for each) ──
const results = await Promise.allSettled([
fetch("/api/a").then(r => r.json()),
fetch("/api/b").then(r => r.json()),
fetch("/api/broken").then(r => r.json()),
]);
// [
// { status: "fulfilled", value: dataA },
// { status: "fulfilled", value: dataB },
// { status: "rejected", reason: errorC },
// ]
for (const r of results) {
if (r.status === "fulfilled") console.log("ok:", r.value);
else console.log("error:", r.reason);
}
// ── Promise.any: first to FULFILL wins (ignores rejections) ──
const firstSuccess = await Promise.any([
fetch("/api/primary").then(r => r.json()), // might fail
fetch("/api/fallback1").then(r => r.json()), // might fail
fetch("/api/fallback2").then(r => r.json()), // succeeds → this wins
]);
// If ALL reject, Promise.any rejects with AggregateError (all errors).
// ── Comparison table ──
// | Method | Waits for | Rejects if | Returns |
// |-------------|-------------|---------------------|-----------------------------|
// | all | All fulfill | Any rejects | Array of values (ordered) |
// | race | First settle| First rejects | First value or error |
// | allSettled | All settle | Never rejects | Array of {status, value/reason} |
// | any | First fulfill| All reject | First value (AggregateError if all fail) |
Error Propagation and Recovery
javascript
// ── Errors skip .then handlers and propagate to .catch ──
Promise.resolve()
.then(() => { throw new Error("A"); }) // throws → rejects
.then(() => console.log("B")) // SKIPPED (rejected)
.then(() => console.log("C")) // SKIPPED
.catch(e => { console.log("caught:", e.message); return "recovered"; }) // catches → fulfills with "recovered"
.then(v => console.log("D:", v)); // "D: recovered" (catch fulfilled the chain)
// ── A .catch that throws re-rejects (propagates further) ──
Promise.reject(new Error("original"))
.catch(e => { console.log("caught:", e.message); throw new Error("re-thrown"); })
.catch(e => console.log("caught again:", e.message)); // "caught again: re-thrown"
// ── Unhandled rejections (no .catch anywhere) ──
// Promise.reject(new Error("unhandled"));
// In Node: "UnhandledPromiseRejection" warning (or process exit with --unhandled-rejections=strict)
// In browser: "unhandledrejection" event fires
// Always add .catch (or use try/catch with async/await) to handle errors!
// ── Recovery with fallback ──
async function fetchWithFallback(url, fallbackUrl) {
try {
return await fetch(url).then(r => r.json());
} catch {
console.warn("primary failed, using fallback");
return await fetch(fallbackUrl).then(r => r.json());
}
}
// ── Graceful degradation with allSettled ──
async function loadWithOptionalEndpoints(critical, optional) {
const [criticalResult, optionalResult] = await Promise.allSettled([
fetch(critical).then(r => r.json()),
fetch(optional).then(r => r.json()),
]);
if (criticalResult.status === "rejected") throw criticalResult.reason;
return {
critical: criticalResult.value,
optional: optionalResult.status === "fulfilled" ? optionalResult.value : null,
};
}
Production Pattern: Concurrent Fetch with Rate Limiting
javascript
// ── Limit concurrent Promises (avoid overwhelming the server) ──
async function mapLimit(items, limit, asyncFn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const i = index++; // claim the next item (atomic increment)
results[i] = await asyncFn(items[i], i); // preserve order
}
}
// Start `limit` workers (each processes items until exhausted)
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// Usage: fetch 100 URLs with max 5 concurrent
const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/${i}`);
const responses = await mapLimit(urls, 5, url =>
fetch(url).then(r => r.json())
);
// ── Batch with retry and timeout ──
async function fetchWithRetry(url, { retries = 3, timeout = 5000 } = {}) {
for (let attempt = 0; attempt < retries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
if (attempt === retries - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); // exponential backoff
}
}
}
💡 Tips & Tricks
javascript
// ── Promise.all for parallel execution (vs sequential await) ──
// ❌ SLOW — sequential (each waits for the previous):
const a = await fetch("/api/a");
const b = await fetch("/api/b");
const c = await fetch("/api/c");
// Total time: a + b + c (sum of all response times)
// ✅ FAST — parallel (all start at once):
const [a2, b2, c2] = await Promise.all([
fetch("/api/a"),
fetch("/api/b"),
fetch("/api/c"),
]);
// Total time: max(a, b, c) (the slowest one)
// ── `Promise.resolve` to wrap a non-Promise value ──
const p = Promise.resolve(42); // fulfilled with 42
// .then unwraps: p.then(v => console.log(v)) // 42
// ── `Promise.allSettled` for "try all, collect results" ──
const results = await Promise.allSettled(urls.map(url => fetch(url).then(r => r.json())));
const successes = results.filter(r => r.status === "fulfilled").map(r => r.value);
const failures = results.filter(r => r.status === "rejected").map(r => r.reason);
// ── `Promise.race` for timeout ──
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
]);
}
const data = await withTimeout(fetch(url), 5000); // rejects after 5s if fetch hasn't completed
// ── Defer a Promise resolve (manual control) ──
let resolveLater;
const deferred = new Promise(resolve => { resolveLater = resolve; });
// resolveLater(42); // resolve later (manual control over when the Promise settles)
⚠️ Edge Cases & Gotchas
javascript
// ── Unhandled rejections crash silently (or crash the process) ──
// Promise.reject(new Error("oops")); // no .catch → UnhandledPromiseRejection
// Always add .catch or use async/await with try/catch.
// ── .then(onFulfilled, onRejected) — errors in onFulfilled are NOT caught by onRejected ──
Promise.resolve()
.then(
() => { throw new Error("in onFulfilled"); },
(e) => console.log("in onRejected:", e) // NOT called! (this catches rejections from the PREVIOUS Promise, not errors in onFulfilled)
)
.catch(e => console.log("caught:", e.message)); // "caught: in onFulfilled" (caught here, not in onRejected)
// ── .finally doesn't receive the value (and doesn't change it) ──
Promise.resolve("data")
.finally(() => console.log("finally")) // no argument — finally doesn't get the value
.then(v => console.log(v)); // "data" (finally didn't change the value)
// If finally returns a value or throws, it DOES affect the chain:
Promise.resolve("data")
.finally(() => "overridden") // returns a value → but the original value is preserved (finally ignores return)
.then(v => console.log(v)); // "data" (finally's return is ignored for non-throwing)
// ── Promise.all rejects on FIRST rejection (others keep running) ──
// Promise.all doesn't cancel the other Promises — they keep running (just ignored).
// Use AbortController to actually cancel ongoing fetches.
// ── Returning a non-Promise from .then wraps it in Promise.resolve ──
Promise.resolve(1)
.then(v => v + 1) // returns 2 (a number) → Promise.resolve(2) → next .then gets 2
.then(v => { console.log(v) }); // 2
// ── Throwing in a .then rejects the returned Promise ──
Promise.resolve()
.then(() => { throw new Error("boom"); }) // returns a rejected Promise
.catch(e => console.log(e.message)); // "boom"
🧠 Quick Quiz
What's the output?
javascript
Promise.resolve("A")
.then(v => { console.log(v); return "B"; })
.then(v => { console.log(v); throw "C"; })
.then(v => console.log(v, "D"))
.catch(e => console.log("E:", e))
.finally(() => console.log("F"));
Answer
A
B
E: C
F
"A"— first .then: logs"A", returns"B"→ fulfills with"B""B"— second .then: logs"B", throws"C"→ rejects with"C"- Third .then is SKIPPED (previous rejected)
.catch(e => ...)— catches"C", logs"E: C"→ fulfills (catch handled it)"F"—.finally()runs regardless → logs"F"
The third .then is skipped because the second .then threw (rejected). The .catch catches the rejection, and .finally runs regardless of fulfill/reject.