Clean Markup

Simplifying Asynchronous Flows with Promise withResolvers

Simplifying Asynchronous Flows with Promise withResolvers

Managing asynchronous operations that complete outside of direct procedural call stacks has long been an awkward facet of JavaScript development. Scenarios like bridging event emitter listeners to promises, queuing concurrent worker tasks, or coordinating stream cancellations historically forced engineers to employ an unwieldy pattern: declaring let resolve, reject; variables in an outer scope, followed by instantiating new Promise((res, rej) => { resolve = res; reject = rej; }). While effective, this "deferred promise" pattern was prone to boilerplate bloat, variable scoping leaks, and missing TypeScript type inference.

Ratified in ECMAScript 2024 and supported natively across all major browsers and runtime environments (Node.js 22+, Deno, and Bun), Promise.withResolvers() standardizes this workflow. Calling Promise.withResolvers() synchronously returns an object containing the unfulfilled promise alongside its extracted resolve and reject functions. By decoupling resolution mechanisms from executor callback closures, developers can create clean, readable, and highly reusable abstractions for event adapters, asynchronous job queues, and cancellation pipelines.

Example

// Native ECMAScript 2024 Promise.withResolvers
const { promise, resolve, reject } = Promise.withResolvers();

// Resolve or reject from anywhere, anytime
setTimeout(() => resolve("Operation completed successfully"), 1000);

const result = await promise;
console.log(result); // "Operation completed successfully"

Implementation

/**
 * Real-world examples utilizing Promise.withResolvers()
 */

// 1. One-time DOM or Node.js Event-to-Promise Adapter
function waitForEvent(target, eventName, timeoutMs = 5000) {
  const { promise, resolve, reject } = Promise.withResolvers();

  const timer = setTimeout(() => {
    cleanup();
    reject(new Error(`Event "${eventName}" timed out after ${timeoutMs}ms`));
  }, timeoutMs);

  function handler(event) {
    cleanup();
    resolve(event);
  }

  function cleanup() {
    clearTimeout(timer);
    target.removeEventListener(eventName, handler);
  }

  target.addEventListener(eventName, handler, { once: true });
  return promise;
}

// 2. Asynchronous Task Queue for Background Workers
class AsyncTaskQueue {
  #concurrency;
  #running = 0;
  #queue = [];

  constructor(concurrency = 2) {
    this.#concurrency = concurrency;
  }

  /**
   * Enqueues an async task and returns a promise that settles when the task executes.
   * @param {() => Promise<any>} taskFn
   * @returns {Promise<any>}
   */
  enqueue(taskFn) {
    const { promise, resolve, reject } = Promise.withResolvers();
    this.#queue.push({ taskFn, resolve, reject });
    this.#processNext();
    return promise;
  }

  async #processNext() {
    if (this.#running >= this.#concurrency || this.#queue.length === 0) {
      return;
    }

    this.#running++;
    const { taskFn, resolve, reject } = this.#queue.shift();

    try {
      const result = await taskFn();
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.#running--;
      this.#processNext();
    }
  }
}

// 3. ReadableStream with Graceful Abort Signal Handling
async function consumeStreamWithTimeout(stream, maxDurationMs) {
  const reader = stream.getReader();
  const { promise: abortPromise, reject: triggerAbort } = Promise.withResolvers();

  const timeoutId = setTimeout(() => {
    reader.cancel("Stream read exceeded maximum duration");
    triggerAbort(new Error("Timeout during stream consumption"));
  }, maxDurationMs);

  try {
    const chunks = [];
    while (true) {
      // Race next chunk against the abort promise
      const readPromise = reader.read();
      const { value, done } = await Promise.race([readPromise, abortPromise]);

      if (done) break;
      chunks.push(value);
    }
    clearTimeout(timeoutId);
    return chunks;
  } finally {
    reader.releaseLock();
  }
}

// Usage demonstration
const queue = new AsyncTaskQueue(2);

const job1 = queue.enqueue(async () => {
  await new Promise((r) => setTimeout(r, 100));
  return "Result of Job 1";
});

const job2 = queue.enqueue(async () => {
  await new Promise((r) => setTimeout(r, 150));
  return "Result of Job 2";
});

const [res1, res2] = await Promise.all([job1, job2]);
console.log(res1, res2);

Main Point: Promise withResolvers provides direct access to promise resolution hooks without outer scope variable hoisting, dramatically simplifying asynchronous queues and event adapters.

Credits

Photo by Evie S. on Unsplash

← Home