loke.dev
Header image for 3 Patterns for Promise.withResolvers That Will Make You Delete Your Deferred Wrappers

3 Patterns for Promise.withResolvers That Will Make You Delete Your Deferred Wrappers

The ES2024 arrival of Promise.withResolvers finally standardizes the 'Deferred' pattern, allowing for much cleaner management of asynchronous state outside of the constructor.

· 4 min read

I spent forty minutes last week debugging a memory leak that turned out to be a "Deferred" wrapper I'd written in 2019. It was one of those fragile pieces of code where I manually hoisted resolve and reject into a higher scope using let variables, feeling like a genius right up until the moment it broke the production build.

With the arrival of ES2024, we can finally stop playing these scoping gymnastics. Promise.withResolvers() is now a standard part of the JavaScript ecosystem (Chrome 119, Firefox 121, Node 22+), and it effectively kills the need for custom "Deferred" classes.

If you’ve ever felt like the new Promise((resolve, reject) => { ... }) constructor was a bit too restrictive—forcing you to put all your logic inside that one callback—this is for you.

What are we actually talking about?

Before we dive into the patterns, here is the "before and after."

The Old Way (Hoisting):

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

// Now you can use resolve() outside the constructor. 
// It works, but it feels like you're holding a live wire.

The New Way:

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

Simple, right? It returns an object containing a new promise and its corresponding resolution functions. No nesting, no let variables, no headaches. Here are three patterns where this actually changes how you write code.

---

1. The "Request-Response" Bridge (WebWorkers & WebSockets)

This is the most common use case I encounter. Imagine you’re sending a message to a WebWorker or a WebSocket. You send the data, but the response comes back in a completely different event listener. Linking that response back to the original request used to require a messy "registry" of resolvers.

With withResolvers, we can create a clean "waiting room" for our requests.

const pendingRequests = new Map();

function sendCommand(id, action) {
  const { promise, resolve, reject } = Promise.withResolvers();
  
  // Store the resolvers to be called later
  pendingRequests.set(id, { resolve, reject });

  worker.postMessage({ id, action });

  // Set a timeout so we don't hang forever
  setTimeout(() => {
    reject(new Error("Request timed out"));
    pendingRequests.delete(id);
  }, 5000);

  return promise;
}

// Somewhere else in your code...
worker.onmessage = (event) => {
  const { id, data, error } = event.data;
  const resolver = pendingRequests.get(id);

  if (resolver) {
    if (error) resolver.reject(error);
    else resolver.resolve(data);
    
    pendingRequests.delete(id);
  }
};

This pattern makes asynchronous communication across different threads or protocols feel like a standard function call.

2. The "Wait Until Ready" Gate

Sometimes your app starts up, but it shouldn't actually *do* anything until a specific condition is met—like a database connection being established or a configuration file loading.

Instead of checking if (isReady) in every single function, you can create a "gate."

class DatabaseClient {
  #readySignal = Promise.withResolvers();
  
  constructor() {
    this.initialize();
  }

  async initialize() {
    try {
      await connectToDB(); // some internal logic
      this.#readySignal.resolve();
    } catch (e) {
      this.#readySignal.reject(e);
    }
  }

  async query(sql) {
    // Every query automatically waits for initialization
    await this.#readySignal.promise;
    return rawQuery(sql);
  }
}

By using #readySignal.promise, you don't need to manage state variables or booleans. You just await the promise. If the database is already ready, the await resolves immediately. If not, it queues up perfectly.

3. Stream-to-Promise Conversion

If you're dealing with legacy event emitters or specific stream logic where you only care about the *final* result (like waiting for a file stream to close or an animation to finish), withResolvers is significantly cleaner than the old constructor approach.

Consider a scenario where you want to wait for a specific "finish" event, but also want to catch "error" events along the way.

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

  emitter.once('close', resolve);
  emitter.once('error', reject);

  // We can also trigger resolution manually based on other logic
  // without being trapped inside the Promise executor scope.
  if (emitter.destroyed) {
    resolve();
  }

  return promise;
}

The beauty here is that you aren't forced to wrap the emitter.once calls inside a giant closure. You can set up your listeners and your resolution logic side-by-side.

---

A Note on Garbage Collection

One thing to keep in mind: because you now have easy access to resolve and reject as independent variables, it’s easier to accidentally keep them in scope longer than you intended.

If you store resolve in a global Map (like in the WebWorker example) and forget to delete it, the associated Promise—and any memory it’s holding onto—won't be garbage collected. Always clean up your maps.

Is it a game changer?

It’s not going to change the world, but it *is* going to change your utility folder. It deletes the "Deferred" boilerplate we’ve been copying and pasting since 2015. It makes your code flatter, reduces indentation, and keeps the "how to resolve" logic right next to the "what to resolve" logic.

Stop hoisting. Start resolving.