The deferred pattern, native and without a helper class
Anyone who wanted to resolve a promise from the outside, from an event handler, a callback API, or some other asynchronous context, had to smuggle resolve and reject out of the constructor scope. Promise.withResolvers() solves this in a single line: { promise, resolve, reject } as a structured object, clear and without boilerplate.
Table of Contents
- 1. The classic problem: resolve outside the constructor
- 2. Syntax and return value of Promise.withResolvers()
- 3. The deferred pattern: what it is and why it came back
- 4. Use case: bridging event queues and one-off events
- 5. Timeout and cancellation mechanisms with withResolvers()
- 6. Subclassing: Promise.withResolvers() on custom promise classes
- 7. Pitfalls: multiple resolves, unhandled rejection, and memory leaks
- 8. Comparison: withResolvers() vs. deferred class vs. constructor trick
- 9. TypeScript integration: typing and generic usage
- 10. Summary
- 11. FAQ
1. The classic problem: resolve outside the constructor
The underlying problem that Promise.withResolvers() solves is as old as promises in JavaScript itself. The new Promise(executor) constructor calls its callback synchronously and only makes resolve and reject available inside that callback. As soon as you create a promise and want to delegate its resolution to an external event handler, say a WebSocket message event, a DOM event, or a legacy callback API, you previously had to smuggle the functions out of the executor scope into an outer variable.
The classic pattern looks like this: let resolve; const p = new Promise(r => { resolve = r; });. This is functionally correct, but implicit, error-prone, and produces TypeScript warnings because resolve is treated as undefined before the assignment. Every team that needs this pattern often enough ends up writing a Deferred helper class. With Promise.withResolvers(), that class becomes unnecessary: the language now provides the pattern natively.
2. Syntax and return value of Promise.withResolvers()
Promise.withResolvers() is a static method on Promise and takes no arguments. It returns an object with three properties: promise, resolve, and reject. The promise object is a regular, unresolved Promise. The functions resolve and reject are the corresponding resolution functions that move the promise into the fulfilled or rejected state. They have the same semantics as the parameters of the normal Promise constructor: calling resolve more than once after the first call has no effect.
The returned object is ideal for destructuring: const { promise, resolve, reject } = Promise.withResolvers();. Three lines of code that used to require a class or a boilerplate block collapse into one. The three variables can now be passed independently to different parts of the program: promise to the consumer, resolve to the event handler, reject to the error handling code. That is exactly the deferred pattern, just without the class.
// Promise.withResolvers(): one-liner for the Deferred pattern
const { promise, resolve, reject } = Promise.withResolvers();
// promise is a standard Promise, consumers await or .then() it
promise.then(value => console.log("Resolved:", value));
promise.catch(error => console.error("Rejected:", error));
// resolve and reject can be called from anywhere, any time
setTimeout(() => resolve("Hello from the future!"), 1000);
// After 1 second: "Resolved: Hello from the future!"
// Compare: old boilerplate (still works, but verbose)
let _resolve, _reject;
const oldPromise = new Promise((res, rej) => {
_resolve = res; // leak from executor scope
_reject = rej;
});
// _resolve is potentially undefined before the sync executor runs
// TypeScript requires '!' or conditional check (messy)
3. The deferred pattern: what it is and why it came back
The deferred pattern is a classic design pattern for asynchronous programming that became known in the JavaScript community through jQuery's $.Deferred(), long before native promises existed. The core idea: a "deferred" object encapsulates a promise together with its resolution functions, so the promise can be controlled from the outside, separate from where it was created and where it gets resolved. With native promises, the pattern disappeared from the official API, because the constructor was a built-in alternative, an inconvenient but technically sufficient one.
The fact that the TC39 committee explicitly brought the deferred pattern back into the language with Promise.withResolvers() is a clear signal: the use case is real and common enough to justify native support. The insight behind it: many asynchronous scenarios in modern JavaScript, event queues, stream bridging, coordinating multiple async operations, require a promise whose resolution happens somewhere in the code other than where it was created. The deferred pattern is the cleanest solution for that, and Promise.withResolvers() makes it the first choice.
4. Use case: bridging event queues and one-off events
The most important use case for Promise.withResolvers() is bridging event-based APIs into promise-based ones. A common scenario: you want to wait for the first occurrence of a DOM event or a Node.js EventEmitter event and represent it as a resolvable promise. With Promise.withResolvers() you can write a clean once(emitter, event) helper function that does exactly that, without classes, without complex lifecycle code.
For more complex scenarios, such as a queue where each entry is controlled by its own Promise.withResolvers() call, a reactive data structure emerges that coordinates any number of asynchronous consumers. This is the foundation for async-iterable queues, "channel" concepts from the Go world in JavaScript, and general producer/consumer architectures. Instead of relying on complex RxJS operators or third-party libraries, such patterns can be implemented directly in native JavaScript with Promise.withResolvers() and a bit of array logic.
// Bridge EventEmitter to Promise with Promise.withResolvers()
function once(emitter, event) {
const { promise, resolve, reject } = Promise.withResolvers();
emitter.addEventListener(event, resolve, { once: true });
emitter.addEventListener("error", reject, { once: true });
return promise;
}
// Usage: await a DOM event as a Promise
const button = document.querySelector("#submit");
const clickEvent = await once(button, "click");
console.log("Button clicked:", clickEvent.target.id);
// Async-iterable queue using Promise.withResolvers()
class AsyncQueue {
#queue = [];
#waiters = [];
enqueue(value) {
if (this.#waiters.length > 0) {
// Resolve the oldest waiting consumer directly
this.#waiters.shift().resolve(value);
} else {
this.#queue.push(value);
}
}
dequeue() {
if (this.#queue.length > 0) {
return Promise.resolve(this.#queue.shift());
}
// No item available, create a Deferred and wait
const deferred = Promise.withResolvers();
this.#waiters.push(deferred);
return deferred.promise;
}
}
const queue = new AsyncQueue();
// Consumer: waits until an item is available
const item = await queue.dequeue(); // suspends until enqueue() is called
5. Timeout and cancellation mechanisms with withResolvers()
Promise.withResolvers() significantly simplifies implementing timeout wrappers. The classic pattern, Promise.race([originalPromise, new Promise(r => setTimeout(() => r("timeout"), ms))]), is correct but has a weakness: the timeout promise cannot be cleaned up when the original resolves first. With Promise.withResolvers() you can hold onto the timeout handle and call clearTimeout as soon as the original promise resolves, before the timeout fires.
Combined with AbortController and AbortSignal, a complete cancellation mechanism emerges: the AbortSignal fires the abort event on cancellation, which triggers reject(signal.reason) directly through an event listener, cleanly, without boilerplate, with real resource cleanup. This combination is the modern pattern for cancellable asynchronous operations in JavaScript and replaces older, more complex approaches with CancellationToken helper classes.
// Timeout wrapper with Promise.withResolvers(): clean timer cancel
function withTimeout(promise, ms) {
const { promise: timeoutP, resolve, reject } = Promise.withResolvers();
const timer = setTimeout(
() => reject(new Error(`Timed out after ${ms} ms`)),
ms
);
// Race the original promise against the timeout
return Promise.race([
promise.finally(() => clearTimeout(timer)), // cancel timer on settle
timeoutP,
]);
}
// AbortSignal integration: cancel via controller
function fetchWithAbort(url, signal) {
const { promise, resolve, reject } = Promise.withResolvers();
// Reject immediately if already aborted
if (signal.aborted) {
reject(signal.reason);
return promise;
}
// Reject on abort event
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
// Resolve on successful fetch
fetch(url, { signal })
.then(r => r.json())
.then(resolve)
.catch(reject);
return promise;
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("User cancelled")), 3000);
const data = await fetchWithAbort("/api/data", controller.signal);
6. Subclassing: Promise.withResolvers() on custom promise classes
A lesser-known feature of Promise.withResolvers(): as a static method, it respects the Symbol.species pattern and the this context it is called on. This means that MyCustomPromise.withResolvers() returns a MyCustomPromise object, not a native Promise. So anyone implementing a custom promise class with extended semantics (for example cancellation, logging, or retry logic) can call Promise.withResolvers() directly on the subclass and receive a fully functional deferred of the correct type.
This makes Promise.withResolvers() useful not only for simple applications but also for libraries that ship their own promise implementations. The method is explicitly specified to use the correct this context: new this(...) instead of new Promise(...). This is the same mechanism that Promise.all(), Promise.race(), and other static methods use to correctly support subclasses.
7. Pitfalls: multiple resolves, unhandled rejection, and memory leaks
The first pitfall with Promise.withResolvers(), and with promises in general, is calling resolve() or reject() more than once. After the first call, the promise is settled and any further resolution is ignored. There is no error, no warning: subsequent calls silently disappear. Anyone who needs to know whether a promise has already been resolved must maintain their own state variable or use a library that detects multiple resolutions.
The second pitfall is unhandled rejection: if reject() is called and no .catch() handler is attached to the promise, this produces an unhandled rejection warning in Node.js and a console error in the browser. With Promise.withResolvers() the risk is higher because promise, resolve, and reject are now separate variables, and the promise may not be passed to every place where it should be consumed. Memory leaks occur when a promise is never resolved while event listeners holding resolve are never removed: the promise and the listeners keep each other alive.
| Pattern | Code overhead | TypeScript-safe | Recommendation |
|---|---|---|---|
| Promise.withResolvers() | 1 line | Yes, natively | Standard as of ES2024 |
| Constructor escape | 3-4 lines + let | Type assertion required | Avoid in new code |
| Deferred class | 10-20 line class | Yes, if generic | Only for legacy projects |
| Library (p-defer etc.) | npm install + import | Yes | No longer needed as of ES2024 |
9. TypeScript integration: typing and generic usage
Promise.withResolvers() has been fully typed since TypeScript 5.4. The return type is PromiseWithResolvers<T>, an interface with the three properties promise: Promise<T>, resolve: (value: T | PromiseLike<T>) => void, and reject: (reason?: unknown) => void. TypeScript infers the type T from the usage context, or expects an explicit type argument: Promise.withResolvers<string>().
A common use case in TypeScript: a function returns a Promise<T> but needs to resolve it in a different method of the same class. Instead of building a complicated state machine with internal callbacks, the class simply holds a PromiseWithResolvers<T> instance as a property and calls this.deferred.resolve(value) once the value is available. This keeps the code linear, easy to follow, and fully type-safe, without additional libraries or manual type definitions.
Mironsoft
Async architecture and TypeScript development for scalable applications
Want to simplify a complex async architecture?
We refactor legacy callback code into modern promise patterns with Promise.withResolvers(), async/await, and AbortController, for maintainable, type-safe JavaScript code.
Async audit
Analysis of callback hell, uncontrolled promises, and unhandled rejections
Refactoring
Migration to Promise.withResolvers(), AbortController, and async iterables
TypeScript
Full typing of async code with PromiseWithResolvers and generic types
10. Summary
Promise.withResolvers() is a seemingly small but, in practice, significant addition in ES2024. It eliminates the widely used boilerplate trick of leaking resolve and reject out of the promise constructor scope, and turns the deferred pattern into a first-class citizen of the JavaScript standard library. The result: clearer code for event bridging, timeout wrappers, async queues, and cancellable operations, without external libraries and without helper classes.
The key points in review: Promise.withResolvers() returns { promise, resolve, reject }. Multiple resolutions are silently ignored. For subclasses, the correct type is inferred via this. TypeScript has fully supported the method since version 5.4. For modern projects targeting Chrome 119+, Firefox 121+, and Node.js 22+, no polyfill is required.
Promise.withResolvers(): The essentials at a glance
Return value
{ promise, resolve, reject }, three independent variables that can be passed to different parts of the code.
Deferred pattern
Decouples promise resolution from promise creation. Ideal for event bridging, queues, timeout wrappers, and cancellable operations.
Pitfalls
Multiple resolves are silent. Unhandled rejection when .catch() is missing. Memory leak when unresolved promises keep event listeners alive.
Compatibility
Chrome 119+, Firefox 121+, Safari 17.4+, Node.js 22+. TypeScript from 5.4. No polyfill needed for modern targets.