JavaScript Promises Deep Dive: How They Work, Chaining, Error Handling and Patterns
Most developers know the basics of Promises β you create one, call .then(), maybe add a .catch(). But interviews and real production bugs demand a deeper understanding: how chaining actually works, when errors silently disappear, and which utility method to reach for in each situation.
This guide goes beyond the basics.
How Promises Work Internally
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It is always in one of three states:
- Pending β initial state, operation not yet complete
- Fulfilled β operation completed successfully, has a value
- Rejected β operation failed, has a reason (error)
Once a Promise settles (fulfills or rejects), it never changes state again. This is called being immutable once settled.
javascriptconst p = new Promise((resolve, reject) => { setTimeout(() => { resolve("done"); // or reject(new Error("failed")) }, 1000); }); console.log(p); // Promise { <pending> } p.then(value => console.log(value)); // "done" after 1 second
The executor function runs synchronously. Only resolve and reject callbacks are async.
Promise Chaining
Every .then() call returns a new Promise. This is what makes chaining work:
javascriptfetch("/api/user/1") .then(response => response.json()) // returns a new Promise .then(user => fetch(`/api/posts?userId=${user.id}`)) // returns a Promise .then(response => response.json()) .then(posts => console.log(posts)) .catch(err => console.error(err));
Each .then() waits for the previous Promise to fulfill before running. If a .then() returns a Promise, the chain waits for that Promise to settle before continuing.
Returning values vs Promises in .then()
javascriptPromise.resolve(1) .then(n => n + 1) // returns 2 (plain value, wrapped in Promise) .then(n => n * 3) // returns 6 .then(n => { return new Promise(resolve => setTimeout(() => resolve(n * 2), 500)); }) // waits 500ms, then continues with 12 .then(n => console.log(n)); // 12
If you return a plain value, it gets wrapped in Promise.resolve(). If you return a Promise, the chain waits for it.
Error Propagation
A rejection skips all .then() handlers until it finds a .catch():
javascriptPromise.resolve("start") .then(v => { throw new Error("something broke"); }) .then(v => console.log("skipped 1")) // skipped .then(v => console.log("skipped 2")) // skipped .catch(err => console.log("caught:", err.message)) // "caught: something broke" .then(v => console.log("continues after catch")); // runs -- catch returns fulfilled Promise
After a .catch() handles an error, the chain resumes as fulfilled unless the .catch() itself throws.
The silent failure trap
The most dangerous Promise mistake β an unhandled rejection with no .catch():
javascript// No .catch() -- error is silently swallowed (or unhandledRejection event) fetchData() .then(process) .then(save); // Always add .catch() fetchData() .then(process) .then(save) .catch(err => { logger.error("Pipeline failed:", err); notifyOncall(err); });
In Node.js, unhandled rejections crash the process in recent versions. Always handle them.
Promise Utility Methods
Promise.all
Runs all Promises concurrently. Resolves when all fulfill. Rejects immediately if any reject:
javascriptconst [user, posts, settings] = await Promise.all([ fetchUser(id), fetchPosts(id), fetchSettings(id), ]); // If any one fails, the whole thing fails // Use when: all results are required and failure of one means total failure
Promise.allSettled
Runs all Promises concurrently. Waits for all to settle regardless of outcome:
javascriptconst results = await Promise.allSettled([ fetchUser(id), fetchPosts(id), fetchOptionalData(id), ]); results.forEach(result => { if (result.status === "fulfilled") { console.log("Success:", result.value); } else { console.log("Failed:", result.reason.message); } }); // Use when: you want all results, partial success is acceptable
Promise.race
Resolves or rejects with the first Promise to settle:
javascript// Implement a timeout const withTimeout = (promise, ms) => Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) ), ]); const result = await withTimeout(fetchData(), 5000);
Promise.any
Resolves with the first fulfilled Promise. Rejects only if all reject (AggregateError):
javascript// Try multiple sources, use whichever responds first successfully const data = await Promise.any([ fetchFromPrimaryServer(), fetchFromSecondaryServer(), fetchFromCache(), ]); // Use when: any one success is enough
Common Anti-Patterns
The Promise constructor anti-pattern
javascript// Bad -- unnecessary wrapping of an already-Promise-returning function function getData() { return new Promise((resolve, reject) => { fetch("/api/data") .then(r => r.json()) .then(data => resolve(data)) .catch(err => reject(err)); }); } // Good -- just return the chain function getData() { return fetch("/api/data").then(r => r.json()); }
Forgetting to return in .then()
javascript// Bug -- the chain does not wait for the inner Promise fetchUser(id) .then(user => { fetchPosts(user.id); // forgot return! }) .then(posts => console.log(posts)); // posts is undefined // Fixed fetchUser(id) .then(user => { return fetchPosts(user.id); // return the Promise }) .then(posts => console.log(posts));
Nested Promises instead of chaining
javascript// Bad -- callback hell with Promises fetchUser(id).then(user => { fetchPosts(user.id).then(posts => { fetchComments(posts[0].id).then(comments => { console.log(comments); }); }); }); // Good -- flat chain fetchUser(id) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => console.log(comments)) .catch(err => console.error(err)); // Better -- async/await const user = await fetchUser(id); const posts = await fetchPosts(user.id); const comments = await fetchComments(posts[0].id);
Promises and async/await
async/await is syntactic sugar over Promises. Every async function returns a Promise:
javascriptasync function loadData() { return 42; // equivalent to: return Promise.resolve(42) } loadData().then(console.log); // 42
try/catch in async functions handles Promise rejections:
javascriptasync function safeLoad() { try { const data = await fetchData(); return process(data); } catch (err) { console.error("Failed:", err); return null; } }
Practice JavaScript on Froquiz
Promises and async patterns are tested in every serious JavaScript interview. Test your JavaScript knowledge on Froquiz from beginner to advanced.
Summary
- Promises have three states: pending, fulfilled, rejected β immutable once settled
- Every
.then()returns a new Promise β this is what enables chaining - Rejections skip
.then()handlers until a.catch()is found Promise.allβ all must succeed;Promise.allSettledβ wait for all regardlessPromise.raceβ first to settle wins;Promise.anyβ first to fulfill wins- Always add
.catch()β unhandled rejections crash Node.js processes - Return Promises inside
.then()or the chain will not wait for them