"A promise isn't a placeholder for a value. It's an object that represents the *eventual completion of an operation*. The distinction matters when chains start failing."
The Three States
A Promise is in exactly one of three states at any moment:
- pending — the operation hasn't finished. No value yet, no error yet.
- fulfilled — the operation succeeded. The promise holds a value.
- rejected — the operation failed. The promise holds a reason (usually an Error).
Promises transition once: pending → fulfilled, or pending → rejected. They never go back. They never transition twice. Once settled (fulfilled or rejected), a promise is immutable — the value or reason it carries can't change.
.then, .catch, .finally — The Three Handlers
You react to a promise by attaching handlers:
fetchUser(id)
.then(user => user.email) // runs on fulfillment
.then(email => sendWelcome(email))
.catch(err => logError(err)) // runs on rejection
.finally(() => closeDB()) // runs either way
Each .then returns a new promise. If your .then handler returns a value, the next promise fulfills with that value. If it returns a promise, the next promise adopts that promise — same state, same value. If it throws, the next promise rejects with the thrown error. The chain is implicit dataflow.
Error Propagation — The Single Killer Feature
.then handlers until it finds a .catch. This is what makes promises beat callbacks: errors propagate by default, instead of requiring you to write if (err) return done(err) on every level. One .catch at the end of a chain handles failures from any step.The trap: forget the catch, and Node logs an
UnhandledPromiseRejection warning. In Node 15+, an unhandled rejection terminates the process by default. Either always end a chain with .catch, or use process.on('unhandledRejection', ...) as a safety net.Promise.all, Promise.race, Promise.allSettled, Promise.any
Four combinators, each with different semantics for handling multiple promises:
Promise.all([p1, p2, p3])— fulfills when ALL fulfill, with an array of values. Rejects as soon as ANY rejects. Use for "I need all of these to succeed."Promise.allSettled([p1, p2])— never rejects; fulfills with an array of{status, value|reason}. Use for "do all of these, tell me which succeeded."Promise.race([p1, p2])— fulfills or rejects with whichever settles first. Use for timeout patterns.Promise.any([p1, p2])— fulfills with the FIRST fulfillment; rejects only if ALL reject. Use for "any of these mirrors is fine."
Picking the right combinator is the difference between a robust fan-out and a brittle one.
You Rarely Construct Promises Directly
In 2026 Node, you almost never call new Promise(executor). The stdlib's fs/promises, dns/promises, timers/promises already return promises. util.promisify wraps callback APIs for you. The handful of times you'd hand-build a Promise are when bridging an event-emitter into promise-land, and even then there's usually a helper.
If you find yourself writing new Promise((resolve, reject) => { ... }) often, suspect that you're reinventing a helper that already exists.
Pippa's Confession
const p = doThing() doesn't make doThing happen later — doThing is already in flight; p is your handle on it. Promises are observers, not schedulers. Once I held that shape, chains stopped being "steps" and started being "reactions."