Promises: combinators and withResolvers
The JavaScript Promise as a three-state value, the four combinators that fold many Promises into one, and Promise.withResolvers for settling one from outside.
Picture a settings page that loads the user, the org, and the recent invoices: three independent reads, one render. Answer four questions about it before reaching for code:
- What does the page do if any one read fails?
- What if the user read is required but the invoices read is merely nice to have?
- What if the org has two replica URLs and either reply is fine?
- What if any read taking longer than two seconds should fail the whole render?
Each of the first three questions calls for a different combinator; the timeout belongs to a separate tool, AbortSignal.timeout(ms), covered in this chapter’s cancellation lesson. Decide which question you’re answering before reaching for Promise.all: only one of these four cases wants it, and the failure modes diverge fast when you pick wrong.
The three states of a Promise
Section titled “The three states of a Promise”A Promise represents work that hasn’t finished yet. It lives in one of three states, and the move out of pending is permanent:
- Pending. No value and no reason yet.
- Fulfilled. The work succeeded; the Promise holds a value.
- Rejected. The work failed; the Promise holds a reason, by convention an
Error.
Once a Promise leaves pending it is settled and never changes again. That permanence is what makes the combinators predictable: every result they inspect is already final.
You create a Promise with new Promise((resolve, reject) => { ... }). The function you pass is the executor ; it runs synchronously and must call resolve or reject exactly once. You rarely write this yourself, since fetch, Drizzle queries, the AI SDK, and other platform helpers hand you a Promise already. The constructor earns its place in two cases: wrapping a callback-style API, and exposing the resolvers so something outside can settle it, which Promise.withResolvers() makes ergonomic below.
The four combinators
Section titled “The four combinators”Each combinator is a static method that takes an array of Promises and returns one Promise; they differ only in what counts as “done.”
- Every result needed →
Promise.all - Every result reported, success or failure →
Promise.allSettled - The first success, others discarded →
Promise.any - The first settlement of any kind →
Promise.race
The table adds two columns: the trigger that makes each combinator the right call, and the failure mode when you pick it for the wrong job.
| Combinator | Resolves when… | Rejects when… | Trigger | Failure mode |
|---|---|---|---|---|
Promise.all | every input fulfills | any input rejects | every value needed | loses other results when one rejects |
Promise.allSettled | every input settles | never | render-what-you-can, per-item decisions | caller forgets to inspect each status |
Promise.any | first input fulfills | every input rejects (AggregateError) | redundant providers, replica reads | fast-but-wrong wins over slow-but-right |
Promise.race | first input settles | first input settles with rejection | composing custom “first to settle” semantics | a fast rejection wins over a slower fulfillment |
The four tabs below share one user/org/invoices skeleton and swap a single combinator. Watch the rejection branch and which results are discarded.
const [user, org, invoices] = await Promise.all([ getUser(userId), getOrg(orgId), listRecentInvoices(orgId),]);Every value needed. The tuple names all three because the caller cannot proceed without any one of them. The reads run concurrently, so total time is max(t1, t2, t3), not the sum. A single rejection discards the other two results, even ones that already fulfilled. To render what you can when something fails, use the next tab.
const [userResult, orgResult, invoicesResult] = await Promise.allSettled([ getUser(userId), getOrg(orgId), listRecentInvoices(orgId),]);
const user = userResult.status === 'fulfilled' ? userResult.value : null;const invoices = invoicesResult.status === 'fulfilled' ? invoicesResult.value : [];Every result reported, success or failure. allSettled never rejects; it returns one { status: 'fulfilled', value } | { status: 'rejected', reason } object per input, in input order. Use it to render what you can when a non-critical read fails. A caller who skips the status check treats the array as plain values and crashes on a rejection that never surfaced.
const org = await Promise.any([ getOrg(primaryOrgUrl), getOrg(replicaOrgUrl),]);The first success, others discarded. Resolves with the first fulfillment, and rejects only when every input rejects, with an AggregateError carrying every reason in .errors. Use it for genuine redundancy: replica reads, a CDN fallback, two analytics endpoints where one acceptance is enough. A fast-but-wrong response can beat a slow-but-right one, and a broken primary stays hidden as long as the replica answers. Reach for it only when the inputs are truly interchangeable, not merely similar.
const result = await Promise.race([ listRecentInvoices(orgId), cancellation.promise,]);The first settlement of any kind. Whichever input settles first wins, fulfillment or rejection. Here one real read is paired with a cancellation flag whose Promise the caller resolves to stop the racer. For “fail if it takes too long,” use AbortSignal.timeout(ms), covered later in this chapter, instead of racing against a timer that leaks and needs manual cleanup. Keep race for custom “first to settle wins” semantics around a cancellation flag.
Promise.any’s rejection shape is easy to get wrong. When every input rejects, the catch sees a single AggregateError whose .errors array holds every reason.
Promise.withResolvers()
Section titled “Promise.withResolvers()”You usually receive a Promise rather than author one: fetch, a Drizzle query, or another platform helper hands you one already. Occasionally you need to settle one from outside the executor:
- Event-driven flows. A socket emits a
messageevent, and you want to expose “the next inbound message” as a Promise. The handler is installed at the call site, not inside an executor. - External resolution. The Promise is created in one place and settled in another: a test fixture, a request-deduplication cache, a once-only “first-load” gate.
The legacy way is the deferred pattern: declare the resolvers as let outside the constructor and capture them inside the executor, which runs synchronously and so assigns them before anyone reads them.
type Message = { id: number; text: string };let resolve: (value: Message) => void;let reject: (reason: Error) => void;const promise = new Promise<Message>((res, rej) => { resolve = res; reject = rej;});socket.once('message', resolve!);socket.once('error', reject!);return promise;Fragile, but it works. The synchronous executor assigns resolve and reject before socket.once reads them. The cost is in the code: two dangling lets, two non-null assertions, and an executor whose only job is to copy its arguments outward. Older projects wrap this boilerplate in a hand-rolled deferred() helper.
type Message = { id: number; text: string };const { promise, resolve, reject } = Promise.withResolvers<Message>();socket.once('message', resolve);socket.once('error', reject);return promise;Same semantics, one line. Promise.withResolvers<Message>() returns the Promise and its two resolvers already bound: no let, no executor, no non-null assertions, no helper. The generic types resolve(value: Message); without it, the resolver would be resolve(value?: unknown).
Promise.withResolvers() ships in current Node and every evergreen browser, no polyfill needed.
The combinators compose Promises that already exist; withResolvers authors one that something external will settle, as in the two cases above. Outside those, the platform API already returns a Promise, so app code reaches for it rarely.
.then, .catch, and .finally
Section titled “.then, .catch, and .finally”Every Promise exposes three instance methods: .then(onFulfilled, onRejected?), .catch(onRejected), and .finally(onSettled). async/await is sugar over .then: the code after each await is what .then would have received as onFulfilled. This course writes await by default.
Two cases still call for .then or .finally, and you’ll see them in third-party code. The first is a wrapper that transforms a result without otherwise being async, like getUser().then((u) => u.id), where adding async would only widen the signature. The second is a .finally cleanup hook called from a synchronous caller that doesn’t want to mark itself async.
Unhandled rejections
Section titled “Unhandled rejections”A Promise that rejects with no .catch, no try/catch around its await, and no combinator that handles rejection is an unhandled rejection, and the runtime’s default response is costly.
In Node 20 and later, an unhandled rejection crashes the process by default, taking the whole server down. In the browser, window fires an unhandledrejection event; the page keeps running, but the error vanishes unless something listens for it. The fix is structural: don’t ship a Promise with no handler attached.
The rule:
Every Promise this app creates is awaited inside a
try/catch, has a.catchattached, or is fed to a combinator whose rejection branch you’ve thought about (allSettled,any).
Promise.resolve() and Promise.reject()
Section titled “Promise.resolve() and Promise.reject()”Promise.resolve(value) returns a Promise already fulfilled with value, and Promise.reject(reason) returns one already rejected with reason. You’ll see them in framework adapters and test helpers, wrapping a synchronous value to fit an async signature; app code rarely reaches for either.
A continuation registered on a Promise.resolve() still schedules as a microtask rather than running synchronously: a pre-resolved Promise does not skip the queue.
Practice: pick the combinator
Section titled “Practice: pick the combinator”Match each scenario to its combinator by asking what counts as “done.”
Pair each scenario with the shape that fits. Note: none of these want Promise.race. Click an item on the left, then its match on the right. Press Check when done.
Promise.allPromise.allSettledPromise.anyPromise.withResolvers().finally(...)Practice: refactor to withResolvers()
Section titled “Practice: refactor to withResolvers()”The starter wraps an EventEmitter-style socket into a Promise with the deferred boilerplate. The tests check that a message event fulfills the Promise, an error event rejects it, and that the body contains Promise.withResolvers.
Rewrite nextMessage so it uses Promise.withResolvers() instead of the deferred boilerplate. Same observable behavior; cleaner shape.
Reveal solution
function nextMessage(socket) { const { promise, resolve, reject } = Promise.withResolvers(); socket.once('message', resolve); socket.once('error', reject); return promise;}Same behavior, with no dangling lets and no throwaway executor whose only job was handing resolve and reject to the outer scope.
External resources
Section titled “External resources”The full reference for the four combinators, the constructor, and every static and instance method.
The standardized deferred pattern. Includes the canonical event-driven example and the comparison to the legacy shape.
The error type `Promise.any` rejects with when every input rejects. The `.errors` array carries each individual reason.
The V8 team's walkthrough of the four combinators with concrete use cases for each — stylesheets, replicas, settled aggregation.
The standardization proposal, including the list of libraries (React, Vue, Vite, Deno) that hand-rolled the deferred pattern before it shipped.