Promise.withResolvers(): The Escape Hatch for Promises You Resolve Later
Promise.withResolvers() is now widely available. A practical guide to what it replaces, where it genuinely helps, and the two places it will quietly hurt you.
There is a pattern almost every JavaScript codebase has a version of: you need a promise now, but the thing that settles it happens somewhere else entirely — an event listener, a message handler, a callback from a library you do not control.
For years the only way to write that was the deferred trick: declare two variables, construct a promise, and reach into the executor to smuggle resolve and reject back out. Promise.withResolvers() is the standard version of that trick, and as of September 2026 it has crossed into widely available on the Baseline scale — supported since Chrome 119, Edge 119, Firefox 121 and Safari 17.4.
The pattern it replaces
Here is the shape you have almost certainly written:
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ...somewhere far away
socket.addEventListener('message', (e) => resolve(e.data));It works, but it is genuinely awkward. The two variables are declared with let because they must be, so they are mutable forever. They are typed as possibly-undefined in TypeScript even though you know the executor runs synchronously. And the whole thing takes six lines to express one idea.
The replacement is one line:
const { promise, resolve, reject } = Promise.withResolvers();
socket.addEventListener('message', (e) => resolve(e.data));Three const bindings, no mutation, no undefined-until-proven-otherwise. Promise.withResolvers() returns a plain object with exactly those three properties. That is the entire API.
Where it actually earns its place
The method is small enough that the interesting question is not how it works but when reaching for it is the right call. Four cases come up repeatedly.
Bridging an event-based API to async/await. Anything where the resolution arrives through a listener rather than a return value — WebSockets, postMessage, a legacy library that takes a callback.
function waitForWorkerReady(worker) {
const { promise, resolve, reject } = Promise.withResolvers();
worker.addEventListener('message', function onMessage(e) {
if (e.data?.type === 'ready') {
worker.removeEventListener('message', onMessage);
resolve(e.data.payload);
}
}, { once: false });
worker.addEventListener('error', reject, { once: true });
return promise;
}A queue of pending requests keyed by id. This is where it shines most, because the resolver has to survive in a data structure until a matching response arrives — exactly the case the old deferred pattern handled worst.
const pending = new Map();
export function request(method, params) {
const id = crypto.randomUUID();
const { promise, resolve, reject } = Promise.withResolvers();
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
return promise;
}
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
const entry = pending.get(msg.id);
if (!entry) return;
pending.delete(msg.id);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.result);
});A one-shot latch. Something that many callers await and one caller opens — a config load, an auth handshake, a feature-flag fetch.
const ready = Promise.withResolvers();
export const whenReady = () => ready.promise;
export const markReady = (value) => ready.resolve(value);Note that calling resolve a second time is a harmless no-op — a promise settles once and ignores everything after — so a latch built this way is naturally idempotent without any guard of your own.
Tests that need to control timing. Being able to hold a promise open, assert on the loading state, and then resolve it on your own schedule is far cleaner than racing a timer.
Two places it will quietly hurt you
This is the part most introductions skip, and both problems come from the same root cause: the promise and the code that settles it are no longer in the same place.
Unhandled rejections get harder to trace. With new Promise(executor), a throw inside the executor automatically rejects the promise. With withResolvers() there is no executor, so a throw in your surrounding code does not reject anything — it propagates normally and leaves the promise pending forever. Any caller awaiting it hangs silently, with no error, no timeout and no stack trace pointing at the cause.
// The bug: setup throws, the promise is never settled,
// and every awaiting caller hangs forever.
function connect() {
const { promise, resolve, reject } = Promise.withResolvers();
const socket = openSocket(); // throws
socket.onopen = () => resolve(socket);
return promise;
}
// The fix: settle it yourself.
function connectSafely() {
const { promise, resolve, reject } = Promise.withResolvers();
try {
const socket = openSocket();
socket.onopen = () => resolve(socket);
socket.onerror = () => reject(new Error('socket failed'));
} catch (err) {
reject(err);
}
return promise;
}Nobody owns the timeout. A deferred promise has no inherent lifetime. In the request-queue example above, if a response never arrives, that entry sits in the Map forever and the caller waits forever. The old executor pattern had the same flaw, but the ergonomics of withResolvers() make the pattern attractive enough that you will use it in more places — so the leak shows up in more places too.
The fix is to give every deferred promise an owner and a deadline. AbortSignal.timeout() pairs with this well:
function requestWithTimeout(method, params, ms = 10000) {
const { promise, resolve, reject } = Promise.withResolvers();
const id = crypto.randomUUID();
const signal = AbortSignal.timeout(ms);
signal.addEventListener('abort', () => {
pending.delete(id);
reject(signal.reason);
}, { once: true });
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
return promise;
}I wrote about the signal side of this separately in the guides on AbortController and AbortSignal and AbortSignal.timeout and AbortSignal.any.
When not to use it
The honest trade-off: Promise.withResolvers() makes a pattern more pleasant to write, and that pattern is one you should mostly be avoiding.
If the async work is already promise-shaped, new Promise() with a real executor is better, not worse — it keeps creation and settlement in one lexically scoped place, and it converts throws into rejections for free. If you are wrapping a callback API in a function that returns immediately, the executor form is still the right answer:
// Still the better shape — don't 'upgrade' this.
const readFile = (path) =>
new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => (err ? reject(err) : resolve(data)));
});withResolvers() is for the genuinely harder case where the resolver must outlive the function that created it. Used there it is a clear improvement. Used as a blanket replacement for new Promise(), it scatters your settlement logic across a file and throws away the automatic error handling the executor gave you.
Support and fallback
Baseline-wise it went newly available in March 2024 and reached widely available in September 2026, which means it is now safe in ordinary browser code without a polyfill for most audiences. Node has supported it since v22.
If you still need to support something older, the polyfill is four lines and needs no feature detection beyond the existence check:
if (typeof Promise.withResolvers !== 'function') {
Promise.withResolvers = function withResolvers() {
let resolve, reject;
const promise = new this((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}Note the new this(...) rather than new Promise(...) — that is what the spec does, and it keeps the method working on Promise subclasses.
The short version
Use Promise.withResolvers() when the thing that settles the promise lives somewhere the executor cannot reach — an event listener, a message router, a map of pending requests. Keep new Promise() when it can.
And whenever you reach for it, write down two things at the same time: what rejects this, and what times it out. The method removes the boilerplate; it does not remove the responsibility that the boilerplate was hiding.