Promise.withResolvers
Sometimes the thing that will settle a promise lives somewhere the promise constructor can’t reach. A button click. A WebSocket message. The next item pushed into a queue. You need the resolve function out here, in the code that will eventually fire — but the constructor keeps it locked inside the executor callback.
For years the workaround was a small, slightly embarrassing ritual: declare a variable with nothing in it, then reach into the executor and assign resolve to it as a side effect. It works. It also reads like a hack, because it is one.
Promise.withResolvers() replaces that ritual with a single line. It hands you the promise and both of its control functions together, already in the scope you want them in.
const { promise, resolve, reject } = Promise.withResolvers();
That’s the whole feature. But the why is where it earns its place, so let’s start with the problem it solves.
The executor traps resolve and reject
When you build a promise the normal way, you pass an executor — a function the constructor calls immediately with two arguments, resolve and reject:
const promise = new Promise((resolve, reject) => {
// resolve and reject only exist in here
setTimeout(() => resolve("done"), 1000);
});
Inside that executor you have everything you need. The trouble starts when the code that will settle the promise isn’t inside the executor. Say you want an event listener elsewhere in your program to resolve this promise. The listener has no access to resolve — it’s a local parameter of a function that already returned.
The old workaround: leak it through a variable
The classic escape hatch is to declare the variables before the promise, then let the executor assign into them. Because the executor runs synchronously — right there during new Promise(...) — the assignments happen before the next line executes, and the outer variables end up holding the real functions.
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// now resolve and reject are usable out here
button.addEventListener("click", () => resolve("clicked"));
A promise you settle from the outside like this has a name: a deferred. Libraries used to ship a Deferred helper class for exactly this shape.
Read that again and notice what’s wrong with it. You declare resolve and reject with no value. You rename them to res and rej inside the executor just to avoid shadowing, then immediately copy them back. Two of the three lines exist purely to move data across a boundary the language put in your way. Nothing about it is wrong — it’s just noise, and noise is where bugs hide (forget one assignment, or typo rej, and the failure is silent until much later).
The new way
Promise.withResolvers() does the smuggling for you. Call it, and it returns a plain object with three properties:
const { promise, resolve, reject } = Promise.withResolvers();
promise— a brand-new pending promise.resolve— the function that fulfills it, same as the executor’s first argument.reject— the function that rejects it, same as the second.
No executor. No pre-declaration. No renaming. The functions come out already sitting in your scope, and you destructure exactly the ones you need.
Here’s the click example again, rewritten:
const { promise, resolve } = Promise.withResolvers();
button.addEventListener("click", () => resolve("clicked"));
const result = await promise; // waits until the click fires
Same behavior, one fewer moving part. And crucially, promise, resolve, and reject are all const now — the old pattern forced them to be let because the executor had to reassign them.
What it returns, precisely
The method takes no arguments and returns a fresh object literal every call — the three properties are plain data properties on an ordinary object, not a special class:
const d = Promise.withResolvers();
d.promise; // Promise { <pending> }
typeof d.resolve; // "function"
typeof d.reject; // "function"
The promise starts life pending. It stays pending until someone calls resolve or reject — and nothing forces that to ever happen. If you drop all references to resolve and reject without calling either, the promise simply stays pending forever. That’s not a leak the way an unsettled new Promise would be; it’s just an unresolved value. Something to keep in mind if an await seems to hang.
The resolve and reject functions behave exactly like the executor’s parameters:
resolve(value)fulfills the promise withvalue. Ifvalueis itself a thenable, the promise adopts its eventual state.reject(reason)rejects withreason.- Only the first call counts. Once settled, later calls to either function are silently ignored — promises can only settle once.
const { promise, resolve, reject } = Promise.withResolvers();
resolve("first");
resolve("second"); // ignored
reject("nope"); // ignored
await promise; // "first"
Try it yourself. Fire the buttons in any order — the first one to land settles the promise, and every later press is logged but ignored. Hit Reset to get a fresh, pending promise.
Where it actually helps
The one-liner is nice, but the real payoff is the shape of code it enables: settling a promise in response to something that happens later, somewhere else. Three patterns show up constantly.
1. Turning an event into a promise
Callback-based APIs — DOM events, Node’s EventEmitter, postMessage — don’t return promises. You often want to await “the next time this fires.” withResolvers bridges the two worlds cleanly:
function nextClick(element) {
const { promise, resolve } = Promise.withResolvers();
element.addEventListener("click", resolve, { once: true });
return promise;
}
// usage
const event = await nextClick(startButton);
console.log("clicked at", event.clientX, event.clientY);
The { once: true } option makes the listener remove itself after firing, so there’s no cleanup dance and no double-resolve to worry about.
This live version awaits nextClick, so the status line stays parked until you actually click — then the awaited event object surfaces its coordinates:
2. A one-shot readiness signal
You have a resource that isn’t ready yet — a WebSocket that’s still connecting, a worker still booting, a font still loading. Other code wants to await “ready” without caring how readiness happens. Store the resolver and fire it once:
class Connection {
#ready = Promise.withResolvers();
constructor(url) {
this.socket = new WebSocket(url);
this.socket.addEventListener("open", () => this.#ready.resolve());
this.socket.addEventListener("error", (e) => this.#ready.reject(e));
}
whenReady() {
return this.#ready.promise;
}
}
const conn = new Connection("wss://example.com/feed");
await conn.whenReady();
conn.socket.send("hello");
Every caller of whenReady() gets the same promise. If the socket is already open, the promise is already fulfilled and the await resolves immediately — promises remember their settled state, so late awaiters aren’t left behind.
3. Backpressure and hand-off queues
The pattern that pushed this feature into the spec is producer/consumer plumbing: a consumer wants the next item, but the producer hasn’t pushed one yet. The consumer parks on a promise; the producer resolves it when an item arrives. This is the backbone of turning a push-based stream into a pull-based async iterator.
class SignalQueue {
#items = [];
#waiting = null;
push(item) {
if (this.#waiting) {
// a consumer is parked — hand the item straight to it
this.#waiting.resolve(item);
this.#waiting = null;
} else {
this.#items.push(item);
}
}
next() {
if (this.#items.length) {
return Promise.resolve(this.#items.shift());
}
// nothing buffered — park until push() wakes us
this.#waiting = Promise.withResolvers();
return this.#waiting.promise;
}
}
Notice the crux: next() creates a fresh withResolvers() each time it has to wait, stashes the resolver, and returns the promise. Later, push finds the parked resolver and calls it. The consumer’s await queue.next() was suspended the whole time; resolving wakes it with the item.
The demo below is exactly that SignalQueue. Press request next when the buffer is empty and the consumer parks on a pending promise (watch the “waiting” line). Press push item and the parked consumer wakes instantly with that item. Push while nobody is waiting and the item buffers for the next request:
Re-arming for repeated signals
A single withResolvers() gives you a one-shot promise — it settles once and it’s done. For a recurring event (a stream that fires “data available” over and over), you replace the resolvers after each cycle. Because you’re just reassigning three variables, this is where destructuring into let earns its keep:
async function* fromReadable(stream) {
let { promise, resolve, reject } = Promise.withResolvers();
stream.on("readable", () => resolve());
stream.on("end", () => resolve());
stream.on("error", (err) => reject(err));
while (stream.readable) {
await promise; // wait for the next "readable"
let chunk;
while ((chunk = stream.read()) !== null) {
yield chunk;
}
// re-arm for the next round
({ promise, resolve, reject } = Promise.withResolvers());
}
}
The parenthesised ({ promise, resolve, reject } = ...) is destructuring assignment to existing variables — the parentheses stop the leading { from being parsed as a block. Each loop iteration awaits the current promise, drains what’s available, then swaps in a fresh set so the next event has something new to resolve.
Gotchas and edge cases
Rejections still need a handler. The promise is created the moment you call withResolvers(). If you reject it and nothing is attached with .catch or await yet, you get an unhandled-rejection warning just like any other promise. Attach your handler (or await) before, or promptly after, the reject can fire.
const { promise, reject } = Promise.withResolvers();
reject(new Error("boom"));
// ⚠️ unhandled rejection unless something is listening
promise.catch(() => {}); // add this to be safe
Don’t leak pending promises where a settled one is expected. If your code path can return without ever calling resolve/reject, any await on that promise hangs forever. In the queue example, a bug that loses the stored resolver would silently deadlock the consumer. When debugging a stuck await, ask: is there a path where nobody settles this?
It’s not a cancellation primitive. A resolver lets you settle from outside, but there’s no built-in “cancel the promise.” If you need cancellation, pair it with an AbortSignal and reject when the signal aborts. withResolvers is the plumbing, not the policy.
Support and status
Promise.withResolvers() is part of ES2024. It reached Baseline “newly available” on 5 March 2024, the day Safari 17.4 shipped and completed the cross-browser set. As of mid-2026 it is on track to cross into Baseline “widely available” in September 2026 (the 30-month mark), so on evergreen browsers you can use it directly.
If you must support older engines, it’s trivially polyfillable — the method has no exotic behavior beyond what the old let-and-executor pattern already did. core-js includes it, and there are tiny standalone packages. A hand-rolled fallback is honestly just:
if (typeof Promise.withResolvers !== "function") {
Promise.withResolvers = function () {
let resolve, reject;
const promise = new this((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
That’s the whole feature in seven lines — which tells you it was never about power, only about deleting boilerplate you were writing by hand anyway.
Summary
Promise.withResolvers()returns{ promise, resolve, reject }in one call, giving you a promise plus its control functions already in your scope.- It replaces the old deferred pattern:
let resolve, reject; new Promise((res, rej) => { resolve = res; reject = rej; }). - Use it whenever something outside the constructor settles the promise: event-to-promise bridges, readiness signals, producer/consumer queues.
- The promise starts pending, settles on the first
resolve/reject, and ignores later calls — standard promise rules. - For recurring signals, re-arm by reassigning a fresh
withResolvers()each cycle with parenthesised destructuring. - Rejections still need a handler, unsettled promises hang forever, and the method depends on
thisbeing a promise constructor — so call it asPromise.withResolvers(). - ES2024, Baseline newly available since March 2024, on track for widely available in September 2026; polyfill in seven lines if you need older support.