intermediate

Promise.withResolvers and the Deferred Pattern (ES2024)

Learn Promise.withResolvers — the ES2024 built-in deferred — how to polyfill it, and when settling a promise from outside is the right design.


The problem it solves

The Promise constructor forces you to decide how the promise settles inside the executor:

const promise = new Promise((resolve, reject) => {
  // resolve/reject only usable in here
});

But real code often needs to settle a promise from somewhere else — an event handler, a message from a worker, a queue consumer. For years everyone wrote the same "deferred" workaround by smuggling the functions out:

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

ES2024 standardizes exactly this as Promise.withResolvers:

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

The polyfill (a one-liner interview question)

if (!Promise.withResolvers) {
  Promise.withResolvers = function () {
    let resolve, reject;
    const promise = new this((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };
}

Two details worth saying out loud in an interview:

  • The executor runs synchronously, so resolve/reject are guaranteed to be assigned before the return — no race.
  • Using new this(...) instead of new Promise(...) keeps it subclass-friendly (MyPromise.withResolvers() returns a MyPromise).

Real uses interviewers accept

1. Bridging event-based APIs

function waitForMessage(worker, matchId) {
  const { promise, resolve } = Promise.withResolvers();
  worker.addEventListener("message", function handler(e) {
    if (e.data.id === matchId) {
      worker.removeEventListener("message", handler);
      resolve(e.data);
    }
  });
  return promise;
}

2. A task queue where the producer awaits the consumer

class TaskQueue {
  #tasks = [];
 
  enqueue(run) {
    const { promise, resolve, reject } = Promise.withResolvers();
    this.#tasks.push({ run, resolve, reject });
    this.#drain();
    return promise; // caller awaits completion of their specific task
  }
 
  async #drain() {
    if (this.#draining) return;
    this.#draining = true;
    while (this.#tasks.length) {
      const { run, resolve, reject } = this.#tasks.shift();
      try { resolve(await run()); } catch (e) { reject(e); }
    }
    this.#draining = false;
  }
  #draining = false;
}

This is the core of the promise pool / concurrency-limit question, where deferreds let queued callers receive their own results.

3. Cancellable waits

Combine a deferred with AbortController to build sleep that can be interrupted:

function sleep(ms, signal) {
  const { promise, resolve, reject } = Promise.withResolvers();
  const id = setTimeout(resolve, ms);
  signal?.addEventListener("abort", () => {
    clearTimeout(id);
    reject(signal.reason);
  });
  return promise;
}

When NOT to use it

A deferred is an escape hatch, not a default. If the entire lifecycle lives in one place, the plain constructor (or just async/await) is clearer and keeps settle-rights private. Handing resolve around widens who can settle the promise — the same reasoning behind "don't expose your setState to strangers." Saying this trade-off unprompted is what makes the answer senior.

Interview follow-ups

  • "What happens if you call resolve twice?" — promises settle once; later calls are ignored (state machine detail from building a Promise from scratch).
  • "How is this different from EventEmitter?" — a promise is single-shot and caches its settlement; an emitter is multi-shot with no memory of past events.
  • "Where does the runtime use this pattern itself?" — streams' ready/closed promises, module loaders, and most test frameworks' expect.poll-style helpers.

  • Object.groupBy, Promise.withResolvers, Set methods, iterator helpers, and the other additions worth knowing in interviews.

  • map, filter, take, and drop directly on iterators (ES2025): lazy evaluation without loading everything into an array.