Stop Exposing Promise Resolvers the Hard Way

Every JavaScript dev has written the "let resolve" promise wrapper. There's a built-in replacement you're not using.

Share

You've written this pattern. Everyone has. You need a promise but also need to call resolve from outside the constructor. So you do the dance:

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

// Later, somewhere else:
resolve("done");

It works. It's ugly. And as of ES2024, it's obsolete.

Why this matters

This pattern shows up in event bridges, worker communication, deferred caches, and test harnesses. The problem isn't just verbosity — it scatters promise logic across scopes, triggers linting warnings for variables used before assignment, and makes code reviews longer than they need to be.

Worse, the pattern is so common that teams stop questioning it. It becomes boilerplate. But there's a built-in API that does exactly what you want, cleanly.

How it works

Promise.withResolvers() returns an object with promise, resolve, and reject — all created atomically in one call.

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

setTimeout(() => resolve("done"), 1000);
const result = await promise; // "done"

No constructor. No outer let declarations. The resolver functions exist the moment the promise does.

Where this helps

  • Worker messages: Create a promise that resolves when a Web Worker posts back. Reject on timeout or error.
  • Deferred request maps: Store resolvers in a Map keyed by request ID. When the response arrives, look it up and resolve.
  • Event-to-promise bridges: Wrap a one-time DOM or IPC event without the wrapper dance.
  • Controlled test flows: Hold the resolver in a test to trigger async completion at the exact moment you choose.

Watch out

The API ships in Node 22+, Deno 1.38+, Bun 1.0.20+, and current browsers (Chrome 119+, Firefox 121+, Safari 17.4+). If you target older runtimes, polyfill it:

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

Yes, the polyfill is literally the pattern it replaces. That's the joke.

Try it yourself

// Deferred task queue with Promise.withResolvers
class TaskQueue {
  pending = new Map();

  enqueue(id) {
    const entry = Promise.withResolvers();
    this.pending.set(id, entry);
    return entry.promise;
  }

  complete(id, value) {
    const entry = this.pending.get(id);
    if (entry) {
      entry.resolve(value);
      this.pending.delete(id);
    }
  }
}

const queue = new TaskQueue();
const result = queue.enqueue("task-1");
queue.complete("task-1", { ok: true });
console.log(await result); // { ok: true }

TL;DR

  • What changed: Promise.withResolvers() gives you a promise plus its resolve and reject in one call.
  • Why it matters: Eliminates the let resolve wrapper anti-pattern from your codebase entirely.
  • What to try: Search your codebase for let resolve and replace every match.