Articles

JavaScript Promise.withResolvers() Explained

Promise.withResolvers() returns a promise plus its resolve and reject functions, replacing the deferred pattern. How it works and when to use it.

Takina Takina · · 5 min read
JavaScript code in a dark-themed code editor

Promise.withResolvers() is a static method that creates a new promise and returns it together with the two functions that settle it: { promise, resolve, reject }. It is the built-in version of the “deferred” pattern developers have hand-written for years, and it’s useful whenever the code that creates a promise is not the same code that decides when that promise finishes.

The problem it solves

The Promise constructor hands you resolve and reject only inside its executor callback:

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

That’s fine when the asynchronous work fits neatly inside the callback — wrapping a single setTimeout or a callback-style API. It gets awkward when settlement depends on something that happens later and elsewhere: an event listener, a message from a worker, a user clicking a button, or a queue draining.

The usual workaround is to smuggle the functions out:

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

This works because the executor runs synchronously, but it’s boilerplate, it requires let bindings that can be accidentally reassigned, and TypeScript complains that the variables may be used before assignment. Most codebases ended up with a small createDeferred() helper copied from project to project.

The new way

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

That one line is functionally the same as the workaround above. The three values are independent: you can return the promise to a caller while keeping resolve and reject in a closure, a class field, or a map keyed by request ID.

Behavior is identical to any other promise. Calling resolve or reject a second time is a no-op, resolving with another promise or thenable adopts its state, and settlement callbacks run as microtasks — see microtasks vs macrotasks for why that ordering matters.

Practical patterns

Turning a one-shot event into a promise

function once(target, type) {
  const { promise, resolve } = Promise.withResolvers();
  target.addEventListener(type, resolve, { once: true });
  return promise;
}

await once(video, "canplay");
video.play();

Matching requests to responses

When talking to a Web Worker, a WebSocket, or an iframe over postMessage, responses arrive on a single shared listener. Keeping a resolver per request makes the call site look like an ordinary async function:

const pending = new Map();
let nextId = 0;

worker.addEventListener("message", (event) => {
  const { id, result, error } = event.data;
  const entry = pending.get(id);
  if (!entry) return;
  pending.delete(id);
  error ? entry.reject(new Error(error)) : entry.resolve(result);
});

function callWorker(method, args) {
  const id = nextId++;
  const { promise, resolve, reject } = Promise.withResolvers();
  pending.set(id, { resolve, reject });
  worker.postMessage({ id, method, args });
  return promise;
}

Waiting for a user decision

class ConfirmDialog {
  open() {
    this.#deferred = Promise.withResolvers();
    this.dialog.showModal();
    return this.#deferred.promise;
  }
  #deferred;
  onConfirm() { this.#deferred.resolve(true); this.dialog.close(); }
  onCancel()  { this.#deferred.resolve(false); this.dialog.close(); }
}

if (await confirmDialog.open()) deleteItem();

Pair this with the native <dialog> element and you get a fully awaitable confirmation flow without a UI library.

Constructor vs withResolvers

new Promise(executor)Promise.withResolvers()
Where resolvers liveInside the executor callbackReturned as plain values
Best forWrapping one self-contained async operationSettling from a different scope or later event
Synchronous throw handlingA throw in the executor rejects the promiseNo executor, so nothing is caught for you
Boilerplate for deferredsOuter let variablesNone
Subclass supportnew MyPromise(...)MyPromise.withResolvers() returns a MyPromise

The third row is the one subtle difference. With the constructor, an exception thrown synchronously inside the executor automatically becomes a rejection. With withResolvers(), there is no executor, so code that might throw while setting things up needs its own try/catch that calls reject. If your async work fits inside a single callback, the constructor is still the clearer choice.

Pitfalls to avoid

Promises that never settle. A deferred whose resolve is never called leaves every await on it hanging forever, and nothing warns you. Always plan the failure path: a timeout, an AbortController signal, or cleanup when a component unmounts.

function withTimeout(ms) {
  const d = Promise.withResolvers();
  const timer = setTimeout(() => d.reject(new Error("Timed out")), ms);
  d.promise.finally(() => clearTimeout(timer)).catch(() => {});
  return d;
}

Leaking resolvers. Storing resolvers in a long-lived map — like the worker example above — means entries accumulate if responses never arrive. Delete them on settlement and on timeout.

Handing out resolve too widely. Whoever holds resolve controls the outcome. Return only promise to callers and keep the resolvers private; exposing them turns a clean API into shared mutable state.

Unhandled rejections. Calling reject on a promise nobody has attached a handler to yet triggers an unhandled-rejection warning. When it’s possible to reject before anyone awaits, attach a handler early or make sure consumers subscribe first.

Composing with other promise tools

Because withResolvers() returns an ordinary promise, everything else in the async toolbox works unchanged. You can await it inside an async function, race it against a timeout, or collect several deferreds and wait on them with the combinators covered in Promise.all vs allSettled vs race. A common pattern is a “ready” signal: a module creates a deferred at load time, exports the promise, and resolves it once initialization finishes, so any number of consumers can await ready regardless of when they start.

Support and fallbacks

Promise.withResolvers() is part of the ECMAScript standard and is available in current versions of all major browsers and in modern Node.js. If you need to support older runtimes, the polyfill is the three-line workaround shown earlier:

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

Using new this(...) preserves the subclassing behavior of the native method. In TypeScript, the return type is PromiseWithResolvers<T>, available when your lib setting includes a recent enough ECMAScript target.

The takeaway

Promise.withResolvers() replaces the hand-rolled deferred: one call gives you a promise plus the resolve and reject functions that control it, usable from any scope. Reach for it when settlement is driven by events, messages, or user actions that happen outside the code creating the promise. Keep the constructor for self-contained async work, keep resolvers private, and always give a deferred a way to fail so it can’t hang forever.

Takina Takina · · 4 min read

Async/Await vs Promises in JavaScript

Async/await is syntactic sugar over Promises, not a different mechanism. How each looks in practice, and when to still reach for raw Promises.

#JavaScript #Web Development #Async
Takina Takina · · 4 min read

Understanding the JavaScript Event Loop

JavaScript is single-threaded, yet pages stay responsive. A clear tour of the call stack, task queue, and microtasks — with examples you can run.

#JavaScript #Web Development #Async