EdgeCases Logo
Feb 2026
JavaScript
Deep
7 min read

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.

javascript
promises
promise-withresolvers
deferred-promises
memory-leak
edge-case

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 instead

Browser 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

Related Insights

Explore related edge cases and patterns

JavaScript
Deep
structuredClone(): The Deep Copy That Isn't Always Deep
7 min

Advertisement