Every JavaScript codebase that’s more than a few years old contains the same nine-line snippet, copy-pasted into a utility module somewhere, sometimes called Deferred or defer() or createDeferredPromise. It exists because the Promise constructor is the wrong shape for a real category of problems: the cases where you need a promise now but you’re going to resolve it from outside the constructor callback. Promise.withResolvers(), which landed in ES2024 and shipped across all major engines through 2024-2025, is the standard library’s answer to that snippet. This article walks through what changed, when to reach for it, and shows the actual Node.js 22 output of both forms running side by side.

The pattern that needed standardising

The classic deferred pattern looks like this in every codebase that ever needed it:

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

The reason this exists: the Promise constructor takes a synchronous executor function, and it gives you the resolve and reject functions only inside that callback. If you want to hold onto resolve and call it from somewhere else later — a websocket message handler, an event listener, a button click — you have to capture them out of the closure. The deferred wrapper does that capture and hands you back all three references in a tidy object.

It’s the kind of utility that everyone reinvents because they don’t realize there’s a standard for it. Bluebird had Promise.defer() for years and removed it because it predated the constructor pattern that won. jQuery had $.Deferred() for the same reason. The TC39 proposal for Promise.withResolvers() finally codified the obvious shape into the language.

What Promise.withResolvers() actually returns

The new method is a static method on Promise that takes no arguments and returns an object with three properties: promise, resolve, and reject. The same shape as the homegrown version, but you don’t write the constructor.

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

button.addEventListener('click', () => resolve('clicked'), { once: true });
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });

const result = await promise; // either 'clicked' or thrown Error

Three lines of meaningful code, zero boilerplate. Compare that to the same pattern with the constructor:

let resolveFn, rejectFn;
const promise = new Promise((res, rej) => {
  resolveFn = res;
  rejectFn = rej;
});
button.addEventListener('click', () => resolveFn('clicked'), { once: true });
signal.addEventListener('abort', () => rejectFn(new Error('aborted')), { once: true });
const result = await promise;

Same behavior. Three extra lines of state management plus the awkward let declarations that have to live above the constructor. The newer form is a strict improvement when this pattern actually fits the problem.

Real run on Node.js 22

Here’s both forms in the same script, executed against node:22-slim:

Terminal output showing Promise.withResolvers running in Node.js 22
Real run on node:22-slim — both the classic deferred pattern and Promise.withResolvers resolve correctly, but the new form removes seven lines of boilerplate.

Both produce the same output. The classic form needed an internal helper function. The new form is one destructuring assignment. typeof Promise.withResolvers reports function on Node 22, confirming the method is built in. On Node 20 you’d see TypeError: Promise.withResolvers is not a function — I hit that on the first run before bumping the runtime — so this is genuinely a feature gate, not a polyfill.

MDN documentation page for Promise.withResolvers
MDN page for Promise.withResolvers — the method is part of ES2024 and shipped in Node.js 22, Chrome 119, Firefox 121, and Safari 17.4.

When this is the right tool

The honest answer: less often than you might think on the first read of the docs. The Promise constructor is still the right shape for the most common case, where the work that resolves the promise is something you can express inside the executor. fetch, file reads, timer-based delays, anything that starts at the same moment the promise is created — those should all still use new Promise(...) or, more commonly, just async/await over the existing async API.

The cases where withResolvers wins are the ones where the resolution is decoupled from creation:

  • Event-driven flows. A websocket receives a message. A DOM element fires an event. A worker posts a message back. You needed the promise to exist before the event handler could capture it.
  • State machines that need to expose pending operations. A queue that returns a promise per enqueued item, resolved when the worker pulls and completes that item, is dramatically cleaner with withResolvers than with manual state.
  • Test fixtures. A mock that wants to control exactly when a promise resolves so the test can assert the in-flight state.
  • Adapters around callback-style APIs. Especially when the callback isn’t passed at construction time but registered through some on-style method.

The anti-pattern is reaching for withResolvers when a plain async function would do the job. If you find yourself writing const { promise, resolve } = Promise.withResolvers(); doSomething().then(resolve); return promise;, you should just return doSomething(). The whole point of the constructor-free form is to handle cases the constructor literally can’t, not to replace the constructor in cases where it works fine.

Concurrency: building a tiny request queue

The most useful real-world example I’ve built with withResolvers is a bounded request queue. The shape is: callers submit work, the queue runs at most N items concurrently, each caller awaits a promise that resolves when their item finishes.

class BoundedQueue {
  constructor(limit) {
    this.limit = limit;
    this.running = 0;
    this.waiting = [];
  }

  async submit(work) {
    if (this.running >= this.limit) {
      const { promise, resolve } = Promise.withResolvers();
      this.waiting.push(resolve);
      await promise;
    }
    this.running++;
    try {
      return await work();
    } finally {
      this.running--;
      const next = this.waiting.shift();
      if (next) next();
    }
  }
}

const queue = new BoundedQueue(3);
const results = await Promise.all(
  urls.map(url => queue.submit(() => fetch(url).then(r => r.json())))
);

Without withResolvers the same shape needs the deferred wrapper or a closure-captured pair. With it, the queueing logic is one line: create a promise, push its resolve into a waiter list, await the promise. The continuation logic is one line: pop a waiter, call it, the previously-blocked submit continues. This is a primitive that’s been lurking in every codebase that does fan-out with rate limiting; the new method turns it into something readable.

Browser and runtime support

Promise.withResolvers shipped earlier than I expected for an ES2024 feature. Node.js picked it up in version 22 (April 2024). Chrome 119 had it in late 2023. Firefox 121 in late 2023. Safari 17.4 in March 2024. The Bun and Deno runtimes both supported it before Node did. As of mid-2026 you can assume the method exists on any JS engine you’d actually deploy to without polyfilling.

If you do need a polyfill for an older runtime, the implementation is exactly the original deferred wrapper:

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

Slightly weird to polyfill the very thing the new method was meant to replace, but if you’re writing a library that targets old runtimes, that’s the answer.

Cancellation and AbortController interactions

One subtlety: withResolvers gives you a resolve and reject pair, but it does not give you any built-in way to cancel a pending promise. Cancellation in JavaScript is still done with AbortController, and if you want a withResolvers-based promise to be cancelable from outside you have to wire it up yourself:

function cancelable(signal) {
  const { promise, resolve, reject } = Promise.withResolvers();
  if (signal.aborted) reject(signal.reason);
  else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
  return { promise, resolve };
}

This pattern composes with the queue example above: pass an AbortSignal into submit, attach the abort listener to the queueing promise, and any caller can cancel their wait while still letting the queue finish other work.

Migrating an existing codebase

If you’re working on a codebase with the homegrown deferred wrapper, the migration is mechanical and worth doing in one pass. The shape of the change is: replace every createDeferred() call with Promise.withResolvers(), then delete the wrapper function. The destructuring pattern is identical so call sites usually don’t need to change at all.

// Before
const deferred = createDeferred();
something.on('done', deferred.resolve);
await deferred.promise;

// After
const { promise, resolve } = Promise.withResolvers();
something.on('done', resolve);
await promise;

The one gotcha I hit during a real migration was a TypeScript codebase where the team had typed their createDeferred wrapper to return a generic Deferred<T>. The TypeScript lib types for Promise.withResolvers use PromiseWithResolvers<T>, which has the same shape but a different name, so any function signatures that referenced the old type need an import update. TypeScript 5.4 added the lib type; earlier TS versions report the method as any if you don’t lift the lib target to ES2024.

For type narrowing on resolve, the new signature is more precise. Promise.withResolvers<User>() gives you a resolve: (value: User | PromiseLike<User>) => void which prevents the common bug where someone calls resolve() with no argument and accidentally resolves a typed promise to undefined. The homegrown wrappers usually didn’t enforce this.

What this means for libraries that ship deferred utilities

Several popular packages on npm — p-defer, defer-promise, extended-promise — exist solely to provide this functionality. As of 2026 they’re effectively obsolete for any project that targets a modern runtime. Removing one of them from your dependency tree is a small but real win: one fewer transitive dep to audit, one fewer thing to update, one fewer place where the abstraction can leak.

For library authors who own one of those packages, the responsible move is to add a deprecation notice in the next minor version that points users at the standard library replacement. Both p-defer and defer-promise have already done this — their READMEs now lead with a note that the package is unnecessary on Node 22+ and modern browsers.

The slightly more interesting case is libraries like RxJS or React Query that internally relied on a deferred-shaped helper for their own implementation. RxJS 8 (in beta as of mid-2026) replaces its internal createDeferred with Promise.withResolvers and removes about a dozen lines of boilerplate. React Query did the same in v5. Neither change is user-visible, but both shrink the bundle slightly and make the source easier to read.

Performance: is there any difference?

For something this small, you’d expect zero meaningful performance difference between the constructor form and withResolvers, and that’s exactly what microbenchmarks show. V8 implements Promise.withResolvers as a thin wrapper over the same machinery the constructor uses; both forms allocate the same number of objects and call the same internal slot writes. I ran a 1-million-iteration benchmark of each form on Node 22 and the variance between runs was larger than the difference between forms.

The one place where there could be a real difference is bundle size in browser code. If you currently ship a deferred utility that adds 100 bytes to your bundle, switching to the built-in saves you those 100 bytes. Negligible for an app, possibly meaningful for a widely-embedded library.

Promise.withResolvers is a small ergonomic win that removes a recurring boilerplate snippet from the language. It’s not a new capability — anything you can build with it you could already build with the constructor and a closure — but the cases where it fits naturally (event-driven flows, state machines, tests, adapters around non-promise APIs) are common enough that it earns its place in the standard library. Reach for it when the work that resolves the promise is genuinely decoupled from creation. Stick with the constructor or plain async functions everywhere else. Both forms are valid; the new one is just the cleaner answer to a question every JavaScript codebase eventually asks.