JavaScript Promises
A promise is an object that represents a value that isn’t ready yet — typically the eventual result of an async operation. It exposes a tiny API (.then, .catch, .finally) that lets you chain steps and handle errors once, rather than nesting callbacks. Promises are the foundation async/await is built on, so understanding them deeply pays off forever.
States and Lifecycle
A promise is always in one of three states:
pending ──resolve(v)──► fulfilled (value = v)
│
└──reject(e) ─────► rejected (reason = e)
Once settled (fulfilled or rejected), a promise never changes state.
Creating a Promise
You’ll rarely write new Promise in app code — most APIs already return promises. But you do need to write one when wrapping a callback API or a non-promise event:
const wait = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
await wait(1000);
console.log("one second later");
function readJSON(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(resolve, reject);
});
}
The executor (resolve, reject) => ... runs synchronously — it should kick off the async work, but the value/error gets delivered async.
Consuming a Promise — then / catch / finally
fetchUser(1)
.then((user) => {
console.log("got", user);
return user.id;
})
.then((id) => fetchOrders(id))
.then((orders) => console.log("orders", orders))
.catch((err) => console.error("something failed", err))
.finally(() => spinner.hide());
Key behaviours:
- Each
.thenreturns a new promise — that’s why chaining works - Returning a value → the new promise resolves with that value
- Returning a promise → the chain waits for it (auto-unwrap)
- Throwing → the chain jumps to the next
.catch .catchis just.then(undefined, onRejected).finally(fn)runs on both success and failure, doesn’t change the value
Auto-Unwrapping
Promise.resolve(1)
.then(x => x + 1) // returns plain value → next .then gets 2
.then(x => Promise.resolve(x * 10)) // returns a promise → chain waits → next gets 20
.then(x => console.log(x)); // 20
You never end up with a Promise<Promise<T>> — promises flatten themselves.
Error Propagation
Errors thrown — or promises rejected — skip downstream .thens and land in the next .catch:
Promise.resolve(1)
.then(x => x.toUpperCase()) // throws TypeError — x is a number
.then(x => console.log("never")) // skipped
.catch(err => console.error("caught:", err.message));
Warning: A promise that rejects with no .catch anywhere in the chain triggers an unhandledrejection event. In modern Node, it crashes the process. Always have a .catch (or use async/await with try).
Promise.resolve / Promise.reject
Quick way to start a chain:
Promise.resolve(42); // already-fulfilled
Promise.reject(new Error("oops")); // already-rejected
// Useful for "wrap if not already a promise"
function ensurePromise(x) {
return Promise.resolve(x); // returns x if it's a promise, else wraps
}
Combining Promises — all, allSettled, race, any
| Method | Resolves when | Rejects when | Returns |
|---|---|---|---|
Promise.all(arr) |
all fulfil | any rejects | array of values |
Promise.allSettled(arr) |
all settle (good or bad) | never | array of {status,value} / {status,reason} |
Promise.race(arr) |
first settles (either way) | first rejects before any fulfils | the first settled value |
Promise.any(arr) |
first fulfils | all reject (AggregateError) | first fulfilled value |
// All — parallel, fail fast
const [user, posts, comments] = await Promise.all([
fetchUser(1), fetchPosts(1), fetchComments(1),
]);
// AllSettled — survive partial failures
const results = await Promise.allSettled([
fetchUser(1), fetchOrders(1), fetchProfile(1),
]);
for (const r of results) {
if (r.status === "fulfilled") console.log(r.value);
else console.warn("failed:", r.reason);
}
// Race — timeout pattern
function withTimeout(p, ms) {
return Promise.race([
p,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), ms)),
]);
}
// Any — first one that works
const firstReply = await Promise.any([
fetch("/mirror-a"), fetch("/mirror-b"), fetch("/mirror-c"),
]);
Sequential vs Parallel
// ❌ Sequential without meaning to (each await blocks the next)
const a = await fetchA();
const b = await fetchB();
const c = await fetchC(); // takes a + b + c ms
// ✅ Parallel
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);
// takes max(a, b, c) ms
If the steps are independent, start them all at once and Promise.all the array. If each step depends on the previous one’s result, sequential is correct.
await Recap
await pauses an async function until the promise settles. It does not block the thread — control returns to the event loop. Errors become regular thrown errors:
async function loadDashboard() {
try {
const user = await fetchUser(1);
const orders = await fetchOrders(user.id);
render(user, orders);
} catch (err) {
showError(err);
}
}
Full chapter on async/await is next — promises are the substrate it stands on.
Promisifying — Bridging Old APIs
import { readFile } from "node:fs";
function readFileP(path, encoding = "utf8") {
return new Promise((resolve, reject) => {
readFile(path, encoding, (err, data) => err ? reject(err) : resolve(data));
});
}
const text = await readFileP("./data.json");
Node ships util.promisify(readFile) for the common Node-style (err, result) => … shape.
A Realistic Example — Retry With Backoff
The same retry logic from chapter 39, now in promise form:
async function retry(fn, { attempts = 3, baseDelayMs = 200 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (i < attempts - 1) {
const delay = baseDelayMs * 2 ** i;
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastErr;
}
// Usage
const config = await retry(() => fetchConfig(), { attempts: 5 });
Compare with the callback version in chapter 39. Same logic, half the code, real error propagation.
Cancellation — AbortController
JavaScript promises themselves can’t be cancelled. Modern APIs accept an AbortSignal so you can abort the underlying work:
const ctrl = new AbortController();
const req = fetch("/api/large", { signal: ctrl.signal });
setTimeout(() => ctrl.abort(), 5000); // give up after 5s
try {
const data = await req;
} catch (e) {
if (e.name === "AbortError") console.log("cancelled");
else throw e;
}
Common Mistakes
// ❌ Forgetting to return inside .then — breaks the chain
fetchUser().then(u => {
fetchOrders(u.id); // promise not returned!
}).then(orders => {
console.log(orders); // undefined
});
// ✅
fetchUser().then(u => fetchOrders(u.id))
.then(orders => console.log(orders));
// ❌ No .catch anywhere
somethingThatMayFail(); // unhandled rejection warning / crash
// ❌ Wrapping a promise in `new Promise` ("promise constructor anti-pattern")
function bad() {
return new Promise((resolve, reject) => {
fetch("/x").then(resolve, reject); // pointless
});
}
// ✅
function good() {
return fetch("/x");
}
// ❌ Awaiting in a tight loop sequentially when parallel is fine
for (const url of urls) await fetch(url); // serial
// ✅
await Promise.all(urls.map(u => fetch(u)));
Best Practices Checklist
- ✅ Return promises from
.thencallbacks to keep the chain flowing - ✅ Always end chains with
.catch(or useasync/await+try) - ✅ Use
Promise.allfor independent parallel work,allSettledwhen failure is acceptable - ✅ Use
Promise.racefor timeouts;Promise.anyfor “first success wins” - ✅ Use
AbortControllerfor cancellable work - ✅ Reach for
async/awaitover manual.thenchains in new code - ❌ Don’t wrap existing promises in
new Promise - ❌ Don’t mix
.then(...)callbacks withawaitfor the same chain — pick one - ❌ Don’t ignore rejection — every async path needs a destination
What’s Next?
Promises form the API. async/await is the ergonomic sugar on top that lets you write async logic as if it were synchronous. Next chapter is where the previous five lessons pay off in spectacular code clarity.