JavaScript Promise.withResolvers Table
| Piece | What it does | Field note |
|---|---|---|
Promise.withResolvers() | Promise + resolve + reject | One destructure: { promise, resolve, reject } - ES2024 |
The old pattern | Let capture from executor | let res; new Promise(r => res = r) - worked, ugly, lint-hated |
Resolve outside the constructor | The whole point | resolve can live in a callback, timer or event handler |
await the promise part | Consume normally | The promise behaves like any other - await/then/catch |
Event-to-promise bridge | The classic use | Resolve from an event handler - one-shot waits |
Stream/task handoff | Deferred kickoff | Pass promise down, resolve later from wherever the data lands |
Resolve once only | Settled is final | Second resolve calls are ignored - safe to call defensively |
vs async function | Different control shapes | async runs NOW; withResolvers is a pause button awaiting external signal |
Promise.withResolvers() replaces the ugliest promise idiom in JavaScript: the let-captured executor. Instead of declaring a variable outside new Promise just so the executor can assign to it, one destructured call hands you the promise AND its resolve and reject as siblings.
Bottom line: withResolvers is a PAUSE BUTTON awaiting an external signal - the resolve can live in a timer, an event handler or another module while the rest of the code awaits the promise side. The classic uses are event-to-promise bridges and deferred task handoffs.
The honest part: the contrast with async/await is control SHAPE, not power. An async function starts running immediately; withResolvers creates a promise that runs when YOU resolve it - which is exactly the deferred-start semantics that executors could never express cleanly.
How to use
- Bridge an event to await: const { promise, resolve } = Promise.withResolvers(); el.onclick = resolve; await promise - the first click unblocks the flow.
- Start a task later: create the pair, pass promise to the consumer now, call resolve(data) from the producer when the work lands - a one-shot handshake.
- Resolve defensively: calling resolve twice is harmless - the second is ignored, settled is final - so shared code paths can resolve without racing checks.
Frequently asked questions
What idiom exactly does Promise.withResolvers() replace?
The executor capture. Creating a promise whose resolve must be called from OUTSIDE the executor forced this shape: let resolveFn; const p = new Promise(r => { resolveFn = r; }); - a mutable outer variable assigned inside the constructor, which lint rules flag and readers parse twice. withResolvers() declares the same thing structurally: const { promise, resolve } = Promise.withResolvers(); - no outer lets, no executor indirection, and the intent ('a promise I control from here') reads in one line. The improvement is purely ergonomic, which is exactly why it took until ES2024: nothing was broken, it was just ugly for twenty years.
When is withResolvers the right tool versus just an async function?
Different control shapes. An async function STARTS EXECUTING when called - its promise resolves when its body finishes. withResolvers creates a promise that does nothing until YOU resolve it - a pause point waiting for an external event that may come from anywhere: a timer, a DOM event, another module, a websocket message. The signal cases: 'wait until the map is loaded', 'resolve when the user confirms', 'start when the worker answers'. If the logic is a sequence your code drives, async/await is the tool; if the logic WAITS for something you cannot express as an await (an event callback, a callback-API result), withResolvers converts that callback into an awaitable promise without wrapping library.
Why is 'settled is final' important for defensive code?
A promise settles exactly once - the first resolve or reject wins, and every later call is silently ignored. withResolvers makes this property USEFUL: code can call resolve unconditionally from multiple plausible paths (the data arrived; also, a timeout fired) without racing guards - whichever path lands first decides the outcome, the rest are no-ops. The timeout pattern shows it: Promise.race([work, timeoutPromise]) where timeoutPromise comes from withResolvers and a setTimeout resolve - even if work finishes late, nothing breaks. The contrast with manual flags (let done = false; if (!done)...) is pure deletion of bookkeeping.
What are the failure modes to keep in mind?
Two. First: resolving from NOWHERE - creating the pair and never calling resolve leaves every awaiter hanging forever (a forgotten-event-listener bug wearing a promise costume); pair withResolvers usage with a timeout path in anything user-facing. Second: rejecting unhandled - if you call reject and nothing awaits or .catches the promise, you get an unhandledrejection warning in production. The discipline mirrors floating promises: withResolvers hands you raw control, so the responsibility for eventual settlement and for handling the result is yours - the constructor-based promises had the same rules, just enforced by their executor's shape.