Promise.withResolvers for Async Queue Creation

The introduction of Promise.withResolvers in ECMAScript 2024 fundamentally simplifies the management of asynchronous workflows by exposing the resolve and reject functions alongside the promise itself. In modern web development, asynchronous queues—structures that process tasks sequentially or throttle concurrent operations—traditionally required awkward scoping hacks to extract resolution capabilities from inside the Promise constructor. This article explains how Promise.withResolvers eliminates boilerplate, improves readability, and provides a robust pattern for building asynchronous queues.

The Problem with Traditional Promise Scoping

Historically, creating a promise whose resolution depends on an external event required an "inverted" design. You had to declare variables in an outer scope and assign them inside the executor function passed to the Promise constructor:

let resolveTask;
let rejectTask;

const taskPromise = new Promise((resolve, reject) => {
  resolveTask = resolve;
  rejectTask = reject;
});

When building an asynchronous queue, consumers often request items before producers provide them. To handle this, the queue must hold pending promises in an internal buffer. Using the traditional pattern, extracting resolve for later execution led to repetitive boilerplate, cluttered scopes, and potential edge cases where the resolver functions might remain unassigned if an error occurred during instantiation.

How Promise.withResolvers Works

Promise.withResolvers() provides a direct, standardized mechanism to create a promise alongside its controls. Calling the method returns a plain JavaScript object containing three properties:

  • promise: The newly created Promise instance.
  • resolve: The function to fulfill the promise.
  • reject: The function to reject the promise.
const { promise, resolve, reject } = Promise.withResolvers();

This direct access decouples the promise creation from the execution logic without requiring closure-based workarounds.

Streamlining an Asynchronous Queue

In an asynchronous queue, producers push data into the queue using an enqueue method, while consumers pull data using a dequeue method. If a consumer requests an item when the queue is empty, the consumer must receive a promise that resolves once a producer adds an item.

Promise.withResolvers makes this coordination straightforward. Instead of tracking disconnected resolver variables, the queue can store the resolver directly in a waiting list.

Here is a streamlined implementation:

class AsyncQueue {
  #items = [];
  #waitingConsumers = [];

  enqueue(item) {
    if (this.#waitingConsumers.length > 0) {
      // Hand the item directly to the oldest waiting consumer
      const { resolve } = this.#waitingConsumers.shift();
      resolve(item);
    } else {
      // Buffer the item until a consumer asks for it
      this.#items.push(item);
    }
  }

  dequeue() {
    if (this.#items.length > 0) {
      // Return buffered item immediately as a resolved promise
      return Promise.resolve(this.#items.shift());
    }

    // No items available: create resolvers and wait
    const { promise, resolve, reject } = Promise.withResolvers();
    this.#waitingConsumers.push({ resolve, reject });
    return promise;
  }
}

Key Advantages in Modern Web Development

  1. Flatter, More Readable Code: Eliminating the nested executor callback removes unnecessary visual noise, making queue control flow direct and linear.
  2. Simplified State Management: Queues can store the { resolve, reject } control objects directly in queues or arrays, treating pending operations as first-class citizens.
  3. Improved Resource Cleanup: With direct access to reject, cancelling operations or clearing the queue on component unmount or stream termination becomes trivial. A queue can iterate over all stored resolvers and reject pending promises with an AbortError.
  4. Standardization: Replacing manual helper utilities or third-party deferred implementations with an official language standard improves performance and cross-team consistency across modern browsers and runtimes like Node.js and Deno.