JavaScript Promises: States, Chaining, Error Handling, and Promise.all
Before promises, async JavaScript meant callbacks nested in callbacks โ โcallback hell,โ where error handling had to be reinvented at every level and control flow read like a staircase viewed sideways. Promises (standardized in ES2015) fixed this by making a future result into a first-class value you can hold, pass around, chain, and combine.
Even though async/await now provides nicer syntax for most promise consumption, promises are the machinery underneath โ and combinators like Promise.all, error-routing rules, and chaining semantics remain daily tools you use directly. This guide builds the model properly.
What a Promise Is
A promise is an object representing an operation whose result isnโt ready yet. It exists in exactly one of three states:
โโโโโโโโโโโ fulfilled (has a value)pending โโโโโโโค โโโโโโโโโโโ rejected (has a reason/error)Two rules make promises trustworthy:
- Settling is permanent. Once fulfilled or rejected, the state and value never change. Late subscribers get the same answer.
- Callbacks always run asynchronously โ as microtasks, even if the promise is already settled when you attach
.then. No โsometimes sync, sometimes asyncโ chaos (a real bug class in pre-promise libraries).
Mostly youโll receive promises from APIs (fetch, timers wrapped in promises, file APIs, every modern SDK). Creating one from scratch is for wrapping callback-style APIs:
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const readClipboard = () => new Promise((resolve, reject) => { legacyClipboardApi.read((err, text) => { if (err) reject(err); // reject with the Error else resolve(text); // fulfill with the value }); });The new Promise((resolve, reject) => ...) executor is the bridge between callback-world and promise-world. If you find yourself writing it around something that already returns a promise, stop โ thatโs the โexplicit construction antipatternโ; use the promise you already have.
.then and the Chaining Rules
.then(onFulfilled) registers a callback for the value โ and, crucially, returns a new promise, which is what makes chaining work:
fetch("/api/user/42") .then((res) => res.json()) // returns a promise โ chain waits for it .then((user) => user.name) // returns a value โ next .then gets it directly .then((name) => console.log(name));The rule that makes chains comprehensible โ what each .then callback returns determines the next link:
- Return a plain value โ the next
.thenreceives it. - Return a promise โ the chain waits for it, then passes its result on. This is the flattening that killed callback hell: sequential async steps become a flat chain, not a pyramid.
- Throw (or return a rejected promise) โ the chain switches to the rejection track.
The classic beginner mistake is breaking the chain by forgetting to return:
fetchUser() .then((user) => { fetchOrders(user.id); }) // BUG: no return โ .then((orders) => render(orders)); // orders is undefined!
fetchUser() .then((user) => fetchOrders(user.id)) // arrow's implicit return fixes it .then((orders) => render(orders));Nothing errors โ the second .then just runs with undefined, immediately, without waiting for the orders fetch. When a chain โlosesโ a value or races ahead, audit the returns first.
Error Handling: The Rejection Track
A promise chain is two parallel tracks โ fulfillment and rejection โ and every link routes between them:
fetch("/api/user/42") .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); // jump to rejection track return res.json(); }) .then((user) => render(user)) .catch((err) => showError(err)) // catches ANY failure above it .finally(() => spinner.hide()); // runs either way; receives nothingThe routing rules:
- A rejection skips forward past every
.thenuntil it meets a.catch(or a.thenwith a second, rejection-handler argument โ rarer style). .catchhandles the error: after it, the chain is back on the fulfillment track (whatever the catch returns feeds the next link). To keep failing, re-throw inside the catch.- Position matters: a
.catchin the middle catches only whatโs above it โ deliberate mid-chain recovery (โif the cache fetch fails, fall back to networkโ) is exactly a mid-chain catch followed by more links. - Throwing inside any
.thencallback rejects its output promise โ synchronous bugs (a typo, a null property) in handlers are captured into the chain, not thrown loose. Good: one error channel. Bad if you forget the channel exists.
Which leads to the golden rule: every chain must end in a .catch (or be awaited inside a try/catch, or returned to a caller who does). An unhandled rejection surfaces as the dreaded UnhandledPromiseRejection console error โ and in Node, can terminate the process. Fire-and-forget promises with no catch are the async equivalent of swallowed exceptions: failures erased from history.
// the sneaky version โ the fetch is "handled" but the HANDLER can fail:fetchUser().then((u) => render(u)); // render throws? unhandled rejection.fetchUser().then((u) => render(u)).catch(log); // sealed.Combinators: Coordinating Multiple Promises
The static methods are where raw promises stay essential even in an async/await world:
Promise.all โ all succeed, or fail fast. The workhorse for independent parallel work:
const [user, orders, prefs] = await Promise.all([ fetchUser(id), fetchOrders(id), fetchPrefs(id),]); // total time โ slowest one, not the sumResolves with an array of results in input order (not completion order). If any input rejects, all rejects immediately with that first error โ right for โI need every pieceโ pages, wrong when partial success is useful.
Promise.allSettled โ wait for everyone, report per-item. For batch operations where one failure shouldnโt sink the rest:
const results = await Promise.allSettled(urls.map((u) => fetch(u)));for (const r of results) { if (r.status === "fulfilled") process(r.value); else logFailure(r.reason);}Never rejects; each entry is {status: "fulfilled", value} or {status: "rejected", reason}.
Promise.race โ first to settle wins (success or failure). The classic use is timeouts:
const result = await Promise.race([ fetchData(), delay(5000).then(() => { throw new Error("timeout"); }),]);Promise.any โ first to fulfill wins, ignoring rejections unless all reject (mirror-image of all) โ think redundant mirrors/endpoints.
The performance habit worth building immediately: spot independent awaits and parallelize them. Sequential fetches of unrelated data is the most common self-inflicted latency in JS applications, and Promise.all is the one-line cure.
A Realistic Composition
Fetch a user, then their orders (dependent โ must chain), while separately loading site config (independent โ parallel), with fallback and cleanup:
function loadDashboard(userId) { const configPromise = fetchConfig().catch(() => DEFAULT_CONFIG); // mid-chain recovery
return fetchUser(userId) .then((user) => Promise.all([user, fetchOrders(user.id), configPromise])) .then(([user, orders, config]) => buildDashboard(user, orders, config)) .catch((err) => { metrics.count("dashboard_load_failed"); throw new DashboardError(`Failed for user ${userId}`, { cause: err }); // wrap, keep cause });}Worth noticing: the config fetch starts immediately (promises begin work when created, not when awaited); its private .catch makes it unsinkable; the dependent steps chain; the final catch translates the error preserving the cause โ the same error-design principles as any language, in promise dialect. The { cause } option on Error is the standard chaining mechanism.
Promisifying the Past, and Promises as Cache Keys
Two patterns that round out working promise fluency.
Promisification at scale. The readClipboard wrapper above generalizes: any callback API with the Node convention ((err, result) => ...) converts mechanically, and Node ships util.promisify to do it for you. In browsers, the stragglers are event-based APIs โ and the wrapper shape is worth knowing because youโll write it for IndexedDB, image loading, and script loading:
const loadImage = (src) => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load ${src}`)); img.src = src; });Once wrapped, the API joins the composable world: Promise.all(srcs.map(loadImage)) preloads a gallery in one line.
The promise-as-cache trick. Because a settled promise happily hands its value to any number of late .thens, caching the promise (not the value) deduplicates concurrent requests elegantly:
const cache = new Map();function getUser(id) { if (!cache.has(id)) { cache.set(id, fetchUser(id).catch((e) => { cache.delete(id); throw e; })); } return cache.get(id); // callers 2..N share the SAME in-flight promise}Ten components requesting user 42 simultaneously produce one network call โ the nine late arrivals attach to the in-flight promise. The .catch-and-delete keeps failures from being cached forever. This pattern (request deduplication) sits inside every data-fetching library youโll ever use; now you can read it โ and itโs a closure + Map construction, stitched with this chapterโs semantics.
Frequently Asked Questions
Do promises make code run in parallel? No โ JS is single-threaded; promises are scheduling, not threading. The waiting overlaps (three network calls in flight together โ thatโs the Promise.all win), but JS callbacks still run one at a time. True CPU parallelism needs workers.
Can I cancel a promise? Not the promise itself โ settling is the only exit. Cancellation is handled at the operation level via AbortController: pass its signal to fetch (or check it in your own code), and abort triggers a rejection. Pattern details in the fetch guide.
Whatโs the difference between .then(f, g) and .then(f).catch(g)? The two-argument formโs g does NOT catch errors thrown inside f โ it only handles rejections from upstream. .catch placed after also covers fโs own failures, which is nearly always what you want; prefer it.
Is .finally just sugar? Almost โ it runs on either outcome, receives no value, and passes the settlement through untouched (perfect for spinners and cleanup). One subtlety: a throw inside .finally replaces the outcome, like finally blocks in Java โ keep it side-effect-only.
Promise.all with an empty array? Resolves immediately with [] โ convenient for โmap over possibly-empty list then allโ pipelines. And non-promise values in the input array are treated as already-fulfilled โ Promise.all([1, fetchX()]) works.
Whatโs Promise.withResolvers()? A recent convenience (ES2024) exposing { promise, resolve, reject } without the executor dance โ for the cases where settle-from-outside is genuinely the design (a deferred you resolve from an event handler). Same power, less nesting; same caution: if youโre deferring routinely, a callback API probably wanted promisifying instead.
Do promises leak if never settled? A pending promise nobody references gets garbage-collected like any object โ no eternal registry. The leak risk is the other direction: closures inside a never-settling executor keep their captures alive, and awaited-forever code paths hold their whole async function frame. Timeouts on external waits are hygiene, not paranoia.
Micro-Glossary for Reading Promise Discussions
Terms youโll meet in docs, issues, and interviews, pinned down: settled โ fulfilled or rejected (no longer pending); resolved โ subtly different: a promise โresolved toโ another promise adopts its eventual state, so a resolved promise can still be pending (this is why the spec says resolve, not fulfill); thenable โ any object with a .then method, which await and Promise.resolve treat as a promise (the interop hook that let pre-standard promise libraries participate); executor โ the (resolve, reject) => {} function passed to new Promise, run synchronously at construction; unhandled rejection โ a rejection with no handler attached by the end of the current microtask drain, surfacing as the console warning or Node event. Knowing โresolved โ fulfilledโ and โthenableโ marks you as someone whoโs read past the tutorials โ and both distinctions occasionally matter in real debugging, when a promise chain adopts a slow inner promise or a libraryโs thenable behaves half-standardly.
Self-Check
// 1 โ what logs, and in what order?Promise.resolve("A") .then((v) => { throw new Error("boom"); }) .then((v) => console.log("then:", v)) .catch((e) => { console.log("catch:", e.message); return "recovered"; }) .then((v) => console.log("after:", v));
// 2 โ how long does this take, roughly?await Promise.all([delay(300), delay(200), delay(100)]);
// 3 โ and this?await delay(300); await delay(200); await delay(100);
// 4 โ spot the unhandled rejection risk:function save(data) { validate(data); return api.post("/save", data);}save(payload).catch(showError);Answers: (1) catch: boom, then after: recovered โ the throw skips the second .then; the catch recovers onto the fulfillment track. (2) ~300ms โ parallel. (3) ~600ms โ sequential; the classic waste when steps are independent. (4) if validate throws synchronously, save throws before returning a promise โ the .catch never attaches. Fix by making save async (sync throws become rejections) or validating inside the chain. Subtle, real, and a perfect segue to the next guide: async/await, where promises get syntax that makes such seams visible.