Every JavaScript developer has written this at least once:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

// Later…
eventBus.on('done', () => resolve(result));

Enter fullscreen mode Exit fullscreen mode

You need to trigger the Promise from outside the constructor — from an event listener, a callback, or a message handler. So you capture resolve and reject by leaking them into the outer scope. It works. It also looks wrong every time you write it, because you're manually escaping a closure that wasn't designed to be escaped.

ES2024 added the right tool for this: Promise.withResolvers.

What Promise.withResolvers returns

Instead of constructing a Promise and escaping its callbacks, you call a static method that hands you all three pieces at once:

const { promise, resolve, reject } = Promise.withResolvers();

Enter fullscreen mode Exit fullscreen mode

promise is a regular Promise. resolve and reject are its resolution functions, already sitting in your scope. No closure escape needed. The pattern that used to take four lines now takes one.

The old pattern and the new one behave identically — the same microtask timing, the same error propagation, the same .then/.catch interface. The difference is that the intent is now explicit: you're creating a deferred Promise on purpose, not as a workaround.

The event-to-Promise bridge

The clearest use case is wrapping event-based APIs in a Promise interface. Before:

function waitForOpen(socket) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  socket.addEventListener('open', () => resolve());
  socket.addEventListener('error', (e) => reject(e));
  return promise;
}

Enter fullscreen mode Exit fullscreen mode

After:

function waitForOpen(socket) {
  const { promise, resolve, reject } = Promise.withResolvers();
  socket.addEventListener('open', () => resolve());
  socket.addEventListener('error', (e) => reject(e));
  return promise;
}

Enter fullscreen mode Exit fullscreen mode

Same logic, much less ceremony. The function returns a Promise that resolves or rejects based on the socket's events — which is exactly what you'd read from the clean version. The old version made you parse around the escape boilerplate to get there.

Async queues and message passing

A place where deferred Promises appear repeatedly is async queues — structures where a consumer awaits the next item and a producer calls push later:

function createQueue() {
  const pending = [];
  let deferred = Promise.withResolvers();

  return {
    push(item) {
      pending.push(item);
      deferred.resolve();
      deferred = Promise.withResolvers();
    },

    async *[Symbol.asyncIterator]() {
      while (true) {
        await deferred.promise;
        while (pending.length) {
          yield pending.shift();
        }
      }
    },
  };
}

const queue = createQueue();

// Consumer
(async () => {
  for await (const item of queue) {
    console.log('received:', item);
  }
})();

// Producer (elsewhere)
queue.push('hello');
queue.push('world');

Enter fullscreen mode Exit fullscreen mode

Each call to Promise.withResolvers() creates a fresh gate. The consumer waits on it; the producer resolves it when there's something to read. Without withResolvers, every one of those gates would need the escape boilerplate. With it, the queue logic reads straight through.

Controlled flushing: waiting for a batch

Another pattern: collecting items for a batch operation and resolving all waiters at once when the batch fires.

class Batcher {
  #items = [];
  #deferred = Promise.withResolvers();

  add(item) {
    this.#items.push(item);
    return this.#deferred.promise;
  }

  async flush() {
    const batch = this.#items.splice(0);
    const { resolve } = this.#deferred;
    this.#deferred = Promise.withResolvers();
    const results = await this.#processBatch(batch);
    resolve(results);
    return results;
  }
}

Enter fullscreen mode Exit fullscreen mode

Callers await batcher.add(item) and get the result when the batch flushes — whether that's triggered by a timer, a size limit, or explicit user action. Each flush resets the gate cleanly with a single Promise.withResolvers() call.

When not to reach for it

Deferred Promises are a tool for specific situations, not a general replacement for the constructor form. If you control both the setup and the resolution — fetch, fs.readFile, any async API you're directly awaiting — use async/await or the Promise constructor directly. Deferred patterns are specifically for when setup and resolution happen in different execution contexts that can't share a callback cleanly.

One concrete anti-pattern: if you find yourself calling resolve synchronously inside the same function that created the deferred, you probably just wanted new Promise(res => res(value)) — which is Promise.resolve(value).

TypeScript support

TypeScript added Promise.withResolvers in version 5.4. The return type is typed correctly — PromiseWithResolvers<T> carries the type through:

const { promise, resolve, reject } = Promise.withResolvers<string>();

resolve('hello');       // ✅ string
resolve(42);            // ❌ type error

Enter fullscreen mode Exit fullscreen mode

If you're on an older TS target, add "ES2024" to lib in your tsconfig.json.

Browser support

Promise.withResolvers is Baseline 2024: Chrome 119, Firefox 121, Safari 17.4, Node.js 22. If you're targeting modern environments, it's available with no polyfill. For older targets, the old escape pattern remains correct — withResolvers is syntax sugar for something you can always write by hand.

The takeaway

Search your codebase for the pattern let resolve or let reject followed by a new Promise. Every one of those is a deferred Promise written the hard way. Promise.withResolvers() names the pattern, eliminates the escape boilerplate, and makes the intent readable at a glance. The result is the same; the code is cleaner by a visible margin.


Thanks for reading! Let's stay connected: