Promises
Prerequisites: you should be comfortable with functions, closures, and callback-based asynchrony, and you should understand that JavaScript runs on a single thread with an event loop. If resolve and reject in a callback-style API still feel unfamiliar, read the preceding chapter first.
The model a Promise actually implements
A Promise is a state machine over an eventual value. It has exactly three states: pending, fulfilled (with a value), or rejected (with a reason, conventionally an Error). A promise settles once, at most. Every subsequent then on a settled promise just replays the outcome — there is no "second event."
This one-way state machine is the source of most of the semantics people find surprising:
- Handlers registered after settlement still fire.
- A
resolvecall later in the executor body is ignored. - There is no way to inspect or cancel the underlying operation from the promise itself.
const p = new Promise((resolve) => {
resolve(1);
resolve(2); // ignored — already settled
});
p.then((v) => console.log(v)); // 1, even if registered much laterThe executor runs synchronously the moment new Promise(...) executes. Only the handlers are asynchronous.
then chains, return values, and flattening
p.then(onFulfilled, onRejected) returns a new promise. That new promise adopts the outcome of whatever the handler does:
- A returned value fulfills it with that value.
- A returned promise/thenable makes the new promise wait for it and adopt its fate. This is flattening, and it is recursive — a
thenhandler can return athenhandler's promise indefinitely. - A thrown error rejects it.
- If the handler is absent (or not a function), the outcome passes through unchanged.
Promise.resolve(2)
.then((n) => n * 10) // 20
.then((n) => Promise.resolve(n + 1)) // waits, resolves 21
.then((n) => { throw new Error('boom'); })
.catch((err) => err.message); // 'boom'Rejecting with a non-Error value is legal but destroys stack traces and breaks instanceof checks downstream. Do it only at an API boundary you control.
Composition combinators
| Combinator | Settles when | Rejection behavior |
|---|---|---|
| `Promise.all` | All fulfill, or any rejects | Fails fast on first rejection |
| `Promise.allSettled` | All settle | Never rejects; returns status/value/reason objects |
| `Promise.race` | First settlement of any kind | Settles with the first outcome, fulfill or reject |
| `Promise.any` | First fulfillment | Rejects with an `AggregateError` only if all reject |
Promise.any is the right tool for "try several mirrors, take whichever answers successfully"; race is wrong for that because a fast failure wins.
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
const failed = results.filter((r) => r.status === 'rejected');Error propagation and recovery semantics
Rejection propagates down the chain until a handler catches it. Crucially, then(f) and then(f, r) differ subtly: in the two-argument form, an error thrown by f is not caught by r in the same call — r only sees rejections of the input promise. p.then(f).catch(r) catches both. Prefer catch.
Once a rejection is handled, the chain continues in the fulfilled state with the recovery value. catch is not a dead end; it is a transform.
Promise.reject(new Error('x'))
.catch(() => 'recovered')
.then((v) => console.log(v)); // 'recovered'Unhandled rejections
A rejection with no handler registered by the end of the current macrotask triggers unhandledrejection (browsers) or an uncaught exception (Node, crash by default since v15). In long-lived chains — caches of promises, then attached conditionally — rejections get silently swallowed or surface much later than the bug. Attach a terminal .catch to every top-level chain.
Cancellation, deferred patterns, and leakage
Promises have no cancellation API. The common escape hatches:
AbortController/AbortSignal— the operation itself honors the signal; the promise rejects with anAbortError. This is the standard for fetch.- Wrapping with a race against a timer — you stop waiting, but the underlying work keeps running and may still resolve later.
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
// promise's work continues even if this rejectsAlso beware executor leakage: an executor that never calls resolve or reject leaves a promise pending forever — the classic "forgot to resolve in a branch" bug that silently hangs await calls. Treat an unconditional settle path as a code-review invariant.
Internals: microtasks, thenables, and sync executors
Every then callback is queued as a microtask, executed after the current synchronous run-to-completion and before any macrotask (timers, I/O). Chained thens therefore drain before a setTimeout(…, 0) fires. Awaiting a native promise costs one microtask tick per link; awaiting a non-native thenable costs extra ticks because the runtime must defensively call its then twice (once with a throwaway attempt to detect a bad thenable).
Decision criteria: promises vs callbacks vs async/await
- New public APIs: always promise-returning. Callbacks are legacy interop only.
- Promises vs async/await:
async/awaitis syntax over promises; choose it for sequential logic because stack traces andtry/catchbehave more like sync code. Choose explicit.thenchains for pipeline-style transformations where each step is a named unit. allvsallSettled: useallonly when a single failure invalidates the whole result; otherwiseallSettledplus explicit triage.
Exercise
Why does p.then(f).catch(r) behave differently from p.then(f, r) when f throws?
Show answer
In then(f, r), r handles only rejections of the input promise p; a throw inside f is unhandled at that link. In then(f).catch(r), the throw rejects the intermediate promise created by then(f), so catch(r) recovers from it.
Exercise
Promise.race([fastFail(), slowSuccess()]) rejects. Which combinator gives you slowSuccess() instead, and what does it reject with if no promise fulfills?
Show answer
Promise.any. If every promise rejects, it rejects with an AggregateError whose errors property holds all the rejection reasons.
Spotted a mistake, or code that does not work?