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 a Promise from outside.
Picture a settings page that loads the user, the org, and the recent invoices. Three independent reads, one render. Now answer four questions about it without 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 one of the three taking longer than two seconds should fail the whole render?
The first three questions each call for a different combinator. The timeout question belongs to a different tool entirely, AbortSignal.timeout(ms), which the cancellation lesson later in this chapter covers. The habit worth building is to decide which question you’re answering before you reach for Promise.all. Only one of these four cases actually wants Promise.all, and the failure modes diverge quickly when you pick the wrong one.
The previous lesson, on the event loop and microtask queue, covered the scheduler: which chunk of code runs next. This lesson covers the contract the scheduler executes against, a Promise as a permanent three-state value, and the four ways to combine several Promises into one.
The three-state model
Section titled “The three-state model”A Promise represents work that hasn’t finished yet. It lives in one of three states, and the transition out of “not finished” is final.
- Pending. The work hasn’t finished. The Promise has no value and no reason yet.
- Fulfilled. The work finished successfully. The Promise holds a value.
- Rejected. The work finished unsuccessfully. The Promise holds a reason, by convention an
Errorinstance.
Once a Promise transitions from pending to fulfilled or rejected, it stays in that state forever. A settled Promise will never transition again. This permanence is what makes the combinators predictable: every result a combinator inspects is already a value, never a moving target.
The low-level way to create one is new Promise((resolve, reject) => { ... }). The function you hand to the constructor is called the executor . It runs synchronously when the Promise is constructed, and its job is to call resolve or reject exactly once. In 2026 app code you rarely write this directly, because fetch, Drizzle queries, the AI SDK, and every other platform helper hand you a Promise already. The constructor still earns its place in two situations: wrapping a callback-style API into a Promise, and exposing the resolvers so that something outside the executor can settle the Promise. That second situation is the one Promise.withResolvers() makes ergonomic, and we’ll get to it once the combinators are in hand.
The four combinators
Section titled “The four combinators”The combinators are four static methods that take an array of Promises and return one Promise. They differ in a single question: 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
Those four lines are the part worth memorizing. The rest of this section unpacks each combinator along two axes: its trigger, the moment in real code where it is the right call, and its failure mode, the way it tends to go wrong 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; any failure fails the whole thing | 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 use the same user/org/invoices skeleton, swapping one combinator at a time so the structural difference is concrete. Flip between them and look at the destructuring shape, the rejection branch, and what falls on the floor.
const [user, org, invoices] = await Promise.all([ getUser(userId), getOrg(orgId), listRecentInvoices(orgId),]);Every value needed. The destructured tuple is the tell: user, org, and invoices are all named because the caller cannot proceed without all three. Total wall-clock time is max(t1, t2, t3), not the sum, since the reads run concurrently. The failure mode is the catch with this combinator: a single rejection discards the other two results, even ones that had already fulfilled. If you want to render what you can when something fails, reach for the next tab instead.
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 hands back an array of { status: 'fulfilled', value } | { status: 'rejected', reason } objects, one per input, in input order. The trigger is render-what-you-can: the page can show something even if a non-critical read fails. The failure mode is quiet. The caller forgets to inspect each entry’s status, treats the array as plain values, and ships code that 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. It only rejects when every input rejects, and then with an AggregateError whose .errors array carries every rejection reason. The trigger is genuine redundancy: replica reads, a CDN fallback, or two analytics endpoints where you only need one to accept. The failure mode is harder to spot than Promise.all’s. A fast-but-wrong response can win over a slow-but-right one, and a regression in the primary won’t surface as long as the replica keeps responding. Reach for this only when the inputs are truly interchangeable, not when they are merely similar.
const result = await Promise.race([ listRecentInvoices(orgId), cancellation.promise,]);The first settlement of any kind. race resolves with the first fulfillment or rejects with the first rejection: whichever input settles first wins, success or failure. The shape here is one real read paired with a cancellation flag whose Promise the caller can resolve to stop the racer. Until recently, the canonical use case was the timeout pattern, Promise.race([fetch(url), timeoutPromise]), but that pattern no longer applies. AbortSignal.timeout(ms), covered in the cancellation lesson later in this chapter, handles the “fail if it takes too long” case now, and does it without the leaked timer and the manual cleanup. race still earns its place when you’re composing custom “first to settle wins” semantics around a cancellation flag, but those cases are rare. If you’re typing Promise.race and the second argument is a timer, reach for AbortSignal.timeout instead.
Promise.any’s rejection shape catches people off guard, so it’s worth a closer look. An AggregateError is a built-in Error subclass with an .errors array. When every replica fails, the catch sees one AggregateError whose .errors contains every individual reason, not just one of them. The full discipline for handling it lands with the error-channel chapter that follows this one. For now, the name to recognize is AggregateError.
Promise.withResolvers()
Section titled “Promise.withResolvers()”Most of the time you don’t author a Promise; you receive one from fetch, from a Drizzle query, or from some other platform helper. But every so often you need to settle a Promise from outside the executor. Two cases come up:
- Event-driven flows. A socket emits a
messageevent and you want to expose “the next inbound message” as a Promise. The code that installs the handler lives at the call site, not inside an executor. - External resolution. Think of a test fixture, a request-deduplication cache, or a once-and-only-once “first-load” gate. The Promise is created in one place and resolved in another.
The legacy way to do this is the deferred pattern: declare the resolvers as let outside the constructor, capture them inside the executor, and trust that the synchronous executor will have assigned 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 executor runs synchronously, so resolve and reject are assigned by the time socket.once reads them, which is what lets this pattern function at all. The cost shows in the code: two dangling lets, two non-null assertions, and an executor whose only purpose is to copy its arguments out into the surrounding scope. Depending on your strictness flags, newer TypeScript also flags let resolve; before assignment as a definite-assignment warning. Most older projects carry a hand-rolled deferred() helper that wraps exactly this boilerplate.
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 an object with the Promise and its two resolvers already bound at destructure time. No let, no executor, no non-null assertions, no helper to import. This is the deferred pattern, standardized. The generic on withResolvers types resolve(value: Message) correctly; without it, the resolver would be resolve(value?: unknown).
Promise.withResolvers() is standardized: it shipped in Node 22 LTS and every evergreen browser, so in 2026 you reach for it without a polyfill or a flag.
Here is the distinction to keep: combinators compose Promises that already exist, while withResolvers is for authoring a Promise that something external will settle. App code reaches for withResolvers rarely, because most of the time the platform API already returns a Promise and the combinators are all you need. You want it when you find yourself wrapping an event emitter, gating on whether the first load has completed, or exposing a Promise that a downstream caller will resolve through some other channel.
Chaining, for recognition
Section titled “Chaining, for recognition”Every Promise exposes three instance methods: .then(onFulfilled, onRejected?), .catch(onRejected), and .finally(onSettled). These are the underlying mechanism. async/await is sugar over .then, where the code after each await is what .then would have received as onFulfilled. This course writes await by default, and the next lesson covers the patterns in depth.
You’ll still see .then and .finally in third-party code in two places worth recognizing. The first is a wrapper that transforms a Promise’s result without otherwise being async: getUser().then((u) => u.id) is concise enough that 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 just to run the cleanup. Outside those two cases, prefer await.
Unhandled rejections
Section titled “Unhandled rejections”A Promise that rejects with no .catch, no surrounding try/catch around an await, and no combinator that handles rejection is an unhandled rejection. The runtime then decides what to do, and the default is costly.
In Node 20 and later, which means every Node version this course runs against, the process crashes by default on an unhandled rejection. The whole server goes down. In the browser, window fires an unhandledrejection event; the page keeps running, but the error disappears silently unless something listens for it. Neither default is one you want to hit in production, and the fix is structural rather than reactive: don’t ship Promises with no handler attached.
Stated as a rule, the habit to build is this:
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).
The throw-vs-return discipline that decides which branch handles a given rejection is the subject of the next chapter on the error channel; here, the rule is enough.
Promise.resolve() and Promise.reject()
Section titled “Promise.resolve() and Promise.reject()”These two synchronous shortcuts are worth naming because you’ll see them in framework code. Promise.resolve(value) returns a Promise already fulfilled with value. Promise.reject(reason) returns one already rejected with reason. Their use is narrow: wrapping a synchronous value to fit an async signature in framework adapters and test helpers. App code rarely reaches for either.
One point ties back to the previous lesson: a continuation registered on a Promise.resolve() still schedules as a microtask. It does not run synchronously. You saw this in the interleaving example built around await Promise.resolve(): a pre-resolved Promise does not skip the queue.
Practice: pick the combinator
Section titled “Practice: pick the combinator”The exercise below pairs scenarios to their combinator, or to Promise.withResolvers(). The decision rule to apply is the one from the start of the lesson: what counts as “done?” Notice that Promise.race is absent from the right-hand side. If your intuition points there, the any framing from the four-combinators section is the correction.
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()”Here is one short refactor so the change in shape is something you write yourself. The starter wraps a fake EventEmitter-style socket into a Promise using the legacy deferred boilerplate. Your job is to rewrite the function body using Promise.withResolvers(), keeping the same observable behavior with a cleaner shape. The tests check that a message event fulfills the Promise, an error event rejects it, and the refactor actually happened (the function body must contain 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 observable behavior, with no dangling lets and no throwaway executor whose sole job was to leak its arguments out into the surrounding 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.