Skip to content
Chapter 7Lesson 2

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.

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.

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.

CombinatorResolves when…Rejects when…TriggerFailure mode
Promise.allevery input fulfillsany input rejectsevery value neededloses other results when one rejects
Promise.allSettledevery input settlesneverrender-what-you-can, per-item decisionscaller forgets to inspect each status
Promise.anyfirst input fulfillsevery input rejects (AggregateError)redundant providers, replica readsfast-but-wrong wins over slow-but-right
Promise.racefirst input settlesfirst input settles with rejectioncomposing custom “first to settle” semanticsa 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.

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.

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 message event, 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.

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.

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.

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 .catch attached, or is fed to a combinator whose rejection branch you’ve thought about (allSettled, any).

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.

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.

Three independent reads; the page should fail if any of them fails.
Promise.all
Three reads; render what succeeded and show a per-item error for the rest.
Promise.allSettled
Two replica URLs for the same read — use whichever replies first with a value.
Promise.any
Subscribe to a socket and expose the next inbound message as a Promise.
Promise.withResolvers()
Hide a loading spinner once a request settles, success or failure, from a non-async caller.
.finally(...)

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.