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 createdPromiseinstance.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
- Flatter, More Readable Code: Eliminating the nested executor callback removes unnecessary visual noise, making queue control flow direct and linear.
- Simplified State Management: Queues can store the
{ resolve, reject }control objects directly in queues or arrays, treating pending operations as first-class citizens. - 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 anAbortError. - 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.