Before Promises, async JavaScript was written entirely with callbacks — functions passed as arguments to be called when an operation completes. Callbacks work, but deeply nested dependent operations create “callback hell” — code that is hard to read, hard to debug, and even harder to handle errors in consistently. Promises were introduced in ES6 to solve these problems: they represent a future value and provide a clean, chainable API for sequencing async operations with unified error handling. In this lesson you will master the callback pattern, all Promise methods, chaining, error handling, and the full suite of Promise.* combinators.
Promise States
| State | Meaning | Transitions To |
|---|---|---|
| Pending | Initial state — operation in progress | Fulfilled or Rejected |
| Fulfilled | Operation succeeded — has a value | Settled (terminal) |
| Rejected | Operation failed — has a reason (error) | Settled (terminal) |
Promise Instance Methods
| Method | Runs When | Returns |
|---|---|---|
.then(onFulfilled, onRejected) |
Fulfilled (or either) | New Promise |
.catch(onRejected) |
Rejected — shorthand for .then(null, fn) |
New Promise |
.finally(fn) |
Either state — cleanup | New Promise (passes through value) |
Promise Static Combinators
| Method | Resolves When | Rejects When | Use For |
|---|---|---|---|
Promise.all(arr) |
ALL resolve | ANY rejects — fast-fail | Parallel independent requests — all required |
Promise.allSettled(arr) |
ALL settle (either state) | Never rejects | Parallel requests — need all results regardless |
Promise.race(arr) |
FIRST to settle | FIRST to reject | Timeout patterns, first-wins |
Promise.any(arr) |
FIRST to fulfill | ALL reject (AggregateError) | Fastest successful source — fallback chains |
Promise.resolve(val) |
Immediately | Never | Wrap a value in a Promise |
Promise.reject(err) |
Never | Immediately | Wrap an error in a Promise |
.then() handler returns a NEW Promise. If the handler returns a value, the new Promise resolves with that value. If it returns a Promise, the new Promise waits for that Promise to settle. If it throws, the new Promise rejects. This chaining behaviour is what makes Promises composable — each step in the chain transforms or passes through the value..catch(), even in development. Unhandled Promise rejections cause warnings in browsers and crash Node.js processes (since Node 15). If you genuinely want to ignore an error for a specific operation, handle it explicitly: .catch(() => null) — this makes the intentional ignore visible in the code.Promise.all fast-fails — if any Promise rejects, the whole thing rejects immediately and the other results are discarded. If you need all results whether they succeed or fail (e.g. a batch of API calls where partial failure is acceptable), use Promise.allSettled and check each result’s status field.Basic Example
// ── Callback hell — the problem Promises solve ────────────────────────────
getUser(userId, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
render(user, posts, comments); // deep nesting — hard to read
});
});
});
// ── Creating a Promise ────────────────────────────────────────────────────
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function fetchUser(id) {
return new Promise((resolve, reject) => {
if (!id) return reject(new Error('ID required'));
setTimeout(() => resolve({ id, name: 'Alice', role: 'admin' }), 300);
});
}
// ── .then chaining — flat structure ──────────────────────────────────────
fetchUser(1)
.then(user => {
console.log('User:', user.name);
return fetchPosts(user.id); // return Promise to chain
})
.then(posts => {
console.log('Posts:', posts.length);
return fetchComments(posts[0].id);
})
.then(comments => console.log('Comments:', comments.length))
.catch(err => console.error('Error:', err.message))
.finally(() => console.log('Done — hide spinner'));
// ── Transforming values in .then ──────────────────────────────────────────
Promise.resolve([1, 2, 3, 4, 5])
.then(nums => nums.filter(n => n % 2 === 0)) // [2, 4]
.then(even => even.map(n => n * 10)) // [20, 40]
.then(result => console.log(result)); // [20, 40]
// ── Promise.all — parallel independent requests ───────────────────────────
async function loadDashboard(userId) {
const [user, posts, notifications] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchNotifications(userId),
]);
return { user, posts, notifications };
}
// ── Promise.allSettled — tolerate partial failure ─────────────────────────
const urls = ['/api/users', '/api/posts', '/api/broken-endpoint'];
const results = await Promise.allSettled(
urls.map(url => fetch(url).then(r => r.json()))
);
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
console.log(`${urls[i]}: OK`, result.value);
} else {
console.warn(`${urls[i]}: FAILED`, result.reason.message);
}
});
// ── Promise.race — timeout wrapper ────────────────────────────────────────
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
await withTimeout(fetchUser(1), 2000); // rejects if fetchUser takes > 2s
// ── Promise.any — first successful CDN ───────────────────────────────────
const asset = await Promise.any([
fetch('https://cdn1.example.com/script.js'),
fetch('https://cdn2.example.com/script.js'),
fetch('https://cdn3.example.com/script.js'),
]).then(r => r.text());
// Uses whichever CDN responds successfully first
How It Works
Step 1 — The Promise Constructor Runs Synchronously
The executor function passed to new Promise((resolve, reject) => { ... }) runs synchronously. Only resolve and reject are asynchronous — calling them schedules .then handlers as microtasks. The Promise object itself is returned synchronously before any .then runs.
Step 2 — Returning a Value vs Returning a Promise in .then
If a .then handler returns a plain value, the next .then receives that value directly. If it returns a Promise, the chain pauses until that Promise settles, then passes its resolved value downstream. If it throws, the chain jumps to the nearest .catch. This consistent behaviour is what makes Promises chainable.
Step 3 — .catch Is Not the End
.catch(fn) handles a rejection and, if it returns a value (not re-throws), the chain continues in the fulfilled state after the catch. This lets you recover from errors mid-chain: .catch(() => defaultValue) means “if this fails, continue with defaultValue.” Only re-throwing or returning a rejected Promise keeps the chain in the rejected state.
Step 4 — Promise.all Fails Fast for Required Data
Promise.all([a, b, c]) is the right tool when all three results are required and any failure should abort the whole operation. It is equivalent to “I need all of these to succeed.” The array destructuring pattern const [user, posts, likes] = await Promise.all([...]) is clean and expressive.
Step 5 — allSettled Gives You Everything Regardless
Promise.allSettled always resolves with an array of result objects — each with a status of 'fulfilled' or 'rejected', and either a value or a reason. This is ideal for batch operations where you want to process whatever succeeded and log or retry whatever failed.
Real-World Example: Data Fetcher with Retry and Cache
// data-fetcher.js
class DataFetcher {
#cache = new Map();
#pending = new Map();
async fetch(url, { retries = 3, timeout = 5000, ttl = 60_000 } = {}) {
// Return cached result if fresh
const cached = this.#cache.get(url);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
// Deduplicate in-flight requests
if (this.#pending.has(url)) {
return this.#pending.get(url);
}
const request = this.#fetchWithRetry(url, retries, timeout)
.then(data => {
this.#cache.set(url, { data, timestamp: Date.now() });
this.#pending.delete(url);
return data;
})
.catch(err => {
this.#pending.delete(url);
throw err;
});
this.#pending.set(url, request);
return request;
}
#fetchWithRetry(url, retriesLeft, timeout) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
return fetch(url, { signal: controller.signal })
.then(res => {
clearTimeout(timer);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
})
.catch(err => {
clearTimeout(timer);
if (retriesLeft <= 1) throw err;
const delay = (4 - retriesLeft) * 1000; // 1s, 2s, 3s back-off
console.warn(`Retrying ${url} in ${delay}ms (${retriesLeft - 1} left)`);
return new Promise(resolve => setTimeout(resolve, delay))
.then(() => this.#fetchWithRetry(url, retriesLeft - 1, timeout));
});
}
invalidate(url) { this.#cache.delete(url); }
clearCache() { this.#cache.clear(); }
}
const fetcher = new DataFetcher();
// All three fire in parallel; result is deduped if called again before resolved
const [users, posts, tags] = await Promise.all([
fetcher.fetch('/api/users'),
fetcher.fetch('/api/posts'),
fetcher.fetch('/api/tags'),
]);
Common Mistakes
Mistake 1 — Forgetting to return inside .then
❌ Wrong — next .then receives undefined:
fetchUser(1)
.then(user => { fetchPosts(user.id); }) // no return!
.then(posts => console.log(posts)); // posts = undefined
✅ Correct — return the Promise to chain it:
fetchUser(1)
.then(user => fetchPosts(user.id)) // return the Promise
.then(posts => console.log(posts)); // posts = actual data
Mistake 2 — Using Promise.all when partial failure is acceptable
❌ Wrong — one failed request discards all results:
const results = await Promise.all(urls.map(fetch));
// If any URL 404s, entire result is lost
✅ Correct — use allSettled for fault-tolerant batch requests:
const results = await Promise.allSettled(urls.map(fetch));
const successes = results.filter(r => r.status === 'fulfilled');
Mistake 3 — Nesting .then instead of chaining
❌ Wrong — recreates callback hell inside Promises:
fetchUser(1).then(user => {
fetchPosts(user.id).then(posts => { // nested!
fetchComments(posts[0].id).then(comments => { ... });
});
});
✅ Correct — return and chain flat:
fetchUser(1)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => { ... });
Quick Reference
| Task | Code |
|---|---|
| Create Promise | new Promise((resolve, reject) => { ... }) |
| Chain success | .then(value => transform(value)) |
| Handle error | .catch(err => handleError(err)) |
| Cleanup always | .finally(() => hideSpinner()) |
| Parallel — all required | Promise.all([a, b, c]) |
| Parallel — tolerate failure | Promise.allSettled([a, b, c]) |
| First to succeed | Promise.any([a, b, c]) |
| First to settle | Promise.race([p, timeout]) |
| Wrap value | Promise.resolve(value) |