Promise.withResolvers() in JavaScript: The Deferred Pattern Without Boilerplate
AI generated
JS
() =>
JavaScript · ES2024 · Promise API
Promise.withResolvers()
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.

9 min read Promise.withResolvers · Deferred Pattern · Async/Await · Event Queue Chrome 119+ · Firefox 121+ · Safari 17.4+ · Node.js 22+

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.

11. FAQ: Promise.withResolvers() in JavaScript

1What is Promise.withResolvers()?
Static ES2024 method, returns { promise, resolve, reject }. Makes resolve/reject accessible outside the constructor, natively, without a helper class.
2What is the deferred pattern?
Encapsulates a promise and its resolution functions together. Resolution can happen at a different point in the code than creation, a classic async pattern.
3When to use it?
Event bridging, timeout wrappers, async queues, cancellable operations, whenever resolution needs to happen from outside the constructor.
4Calling resolve() multiple times?
Only the first call has an effect. Further calls are silently ignored, no error. Maintain your own state variable to detect this.
5Difference from a deferred class?
Functionally identical. Promise.withResolvers() replaces the class with a one-liner, native, typed, without an external dependency.
6TypeScript support?
Since TypeScript 5.4. Return type: PromiseWithResolvers<T>. Generics: Promise.withResolvers<string>(). T is inferred from context.
7Avoiding memory leaks?
{ once: true } on event listeners, or explicitly call removeEventListener(). Unresolved promises are GC candidates once all references are gone.
8Combination with AbortController?
Yes. signal.addEventListener("abort", () => reject(signal.reason), { once: true }). A clean cancellation mechanism without a library.
9Works with subclasses?
Yes. MyCustomPromise.withResolvers() returns a MyCustomPromise instance, this is used as the constructor.
10Browser support?
Chrome 119, Firefox 121, Safari 17.4, Node.js 22. Simple polyfill using the constructor escape pattern for older targets.