Promise.withResolvers(): The Pattern You've Been Hacking Around
Promise.withResolvers() eliminates the deferred promise hack, but leaked resolvers create memory leaks that are harder to spot than the old pattern.
For years, the "deferred promise" was JavaScript's dirtiest common pattern: declare let resolve, reject
outside the executor, assign them inside, and hope nobody asks about the code smell. Promise.withResolvers()
(ES2024, TC39 Stage 4) makes this a first-class API — but it also makes new footguns easier to reach.
The Pattern It Replaces
// ❌ The old way — mutable bindings, awkward scoping
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
// ✅ The new way — clean destructuring
const { promise, resolve, reject } = Promise.withResolvers();
Functionally identical. But the old pattern required let bindings and relied on the executor
running synchronously (which it does — but that's not obvious). Promise.withResolvers()
returns a plain object with the promise and its control functions in one shot.
Where It Actually Shines
The real value isn't in replacing a three-liner. It's in scenarios where the resolver outlives the promise creation scope — event listeners, streams, and recurring async patterns:
Stream to Async Iterable
async function* streamToAsyncIterable(stream) {
let { promise, resolve, reject } = Promise.withResolvers();
stream.on('error', (err) => reject(err));
stream.on('end', () => resolve());
stream.on('readable', () => resolve());
while (stream.readable) {
await promise;
let chunk;
while ((chunk = stream.read())) {
yield chunk;
}
// Create a fresh promise for the next batch
({ promise, resolve, reject } = Promise.withResolvers());
}
}
Each iteration creates a new deferred that the event listeners resolve. Without withResolvers(),
you'd need to re-wrap the executor on every iteration. This pattern also appears in queue consumers,
WebSocket message handlers, and custom async coordination primitives.
One-Shot Event Bridges
function waitForEvent(target, eventName, options) {
const { promise, resolve, reject } = Promise.withResolvers();
const controller = new AbortController();
const { signal } = controller;
target.addEventListener(eventName, resolve, { ...options, signal });
// Timeout safety
if (options?.timeout) {
setTimeout(() => {
controller.abort();
reject(new Error(`Timeout waiting for ${eventName}`));
}, options.timeout);
}
return promise;
}
// Usage
const click = await waitForEvent(button, 'click', { timeout: 5000 });The Memory Leak Footgun
Here's the edge case that bites in production. When resolve and reject
are captured by long-lived closures (event listeners, module scope, caches), the promise
and everything it references stays in memory — even if you never settle it:
// ❌ Memory leak: promise never settles, never GC'd
const pending = new Map();
function registerCallback(id) {
const { promise, resolve } = Promise.withResolvers();
pending.set(id, resolve); // resolve holds a ref to promise
return promise;
}
// If the caller never calls pending.get(id)(), the promise
// and its entire closure chain lives forever in the Map
The fix: always pair creation with a cleanup path. Timeouts, AbortController,
or explicit WeakRef patterns:
// ✅ With cleanup
function registerCallback(id, timeoutMs = 30000) {
const { promise, resolve, reject } = Promise.withResolvers();
pending.set(id, resolve);
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Callback ${id} timed out`));
}, timeoutMs);
// Clean up timer on resolution
promise.finally(() => {
clearTimeout(timer);
pending.delete(id);
});
return promise;
}Subclassing Behavior
Promise.withResolvers() is generic — it respects this.
Calling it on a Promise subclass returns an instance of that subclass:
class TrackedPromise extends Promise {
constructor(executor) {
super(executor);
this.createdAt = Date.now();
}
}
const { promise } = TrackedPromise.withResolvers();
promise instanceof TrackedPromise; // ✅ true
promise.createdAt; // ✅ timestamp
This works because the spec calls new this(executor) internally.
But it also means that if you call Promise.withResolvers.call(NotAPromise),
the "resolve" and "reject" functions are whatever the foreign constructor passes to the executor.
They may not behave like real promise resolvers — no state machine, no idempotency guarantees.
Idempotency Reminder
Like all promises, calling resolve() or reject() more than once
is a no-op — the promise settles exactly once. But now that the resolvers live outside the
executor, it's easier to accidentally call them multiple times:
const { promise, resolve } = Promise.withResolvers();
// Event fires multiple times, but only the first counts
emitter.on('data', (chunk) => {
resolve(chunk); // Second+ calls are silently ignored
});
// If you need all events, use the stream pattern above insteadBrowser Support & Polyfill
Supported in Chrome 119+, Firefox 121+, Safari 17.4+, Node 22+. For older environments, the polyfill is trivial:
if (!Promise.withResolvers) {
Promise.withResolvers = function () {
let resolve, reject;
const promise = new this((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
Note the polyfill uses new this() to preserve subclassing behavior, matching the spec.
Key Takeaways
- Use
Promise.withResolvers()when resolvers need to escape the executor scope — events, streams, queues - For simple async wrapping, the
new Promise()constructor is still cleaner and more explicit - Always pair deferred promises with a cleanup path (timeout, abort,
finally) to prevent memory leaks - The method is generic — it respects subclasses via
new this() - Resolvers are idempotent: second calls are no-ops, not errors
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement