DEV Community

Omri Luz
Omri Luz

Posted on • Edited on

Promise Combinators: race, allSettled, any

Warp Referral

Promise Combinators: race, allSettled, and any

JavaScript's approach to handling asynchronous operations underwent a significant transformation with the introduction of Promises in ECMAScript 2015 (ES6). These objects provide a robust paradigm for dealing with asynchronous computations with improved readability and maintainability over traditional callback-based patterns. Among the most powerful features of Promises are combinators—functions that take multiple Promises and offer various strategies for their resolution. This article delves into three essential Promise combinators: Promise.race(), Promise.allSettled(), and Promise.any(), their inner workings, edge cases, real-world applications, and performance considerations.

Historical Context of Promises in JavaScript

Prior to the introduction of Promises, asynchronous programming in JavaScript was dominated by callback functions. While this approach managed to be functional, it introduced significant challenges such as "callback hell," which made the code increasingly difficult to read and maintain.

The Promise construct revolutionized this landscape by encapsulating the eventual completion (or failure) of an asynchronous operation and its resulting value. It also introduced a chaining mechanism that allowed developers to work with a sequence of asynchronous operations more elegantly.

The Promise API

The core of the Promise API is built around the following states:

  1. Pending - The initial state. The operation is still ongoing.
  2. Fulfilled - The operation completed successfully.
  3. Rejected - The operation failed.
const myPromise = new Promise((resolve, reject) => {
  // Asynchronous operation
  if (/* operation successful */) {
    resolve("Success!");
  } else {
    reject("Failure!");
  }
});
Enter fullscreen mode Exit fullscreen mode

Introduction to Promise Combinators

Combinators allow developers to work with multiple Promises in various contexts. The three we will explore are Promise.race(), Promise.allSettled(), and Promise.any().

1. Promise.race()

Deep Dive

Promise.race(iterable) takes an iterable of Promise objects and returns a single Promise that resolves or rejects as soon as one of the promises in the iterable resolves or rejects, with the value or reason from that promise.

const promise1 = new Promise((resolve) => 
  setTimeout(resolve, 500, 'First promise resolved')
);
const promise2 = new Promise((resolve) => 
  setTimeout(resolve, 100, 'Second promise resolved')
);

Promise.race([promise1, promise2]).then((value) => {
  console.log(value); // Output: "Second promise resolved"
});
Enter fullscreen mode Exit fullscreen mode

Edge Cases

  • Rejection Handling: If the first promise to settle is rejected, the resulting Promise from race() is also rejected.
const promise1 = new Promise((resolve) => setTimeout(resolve, 300, "Success!"));
const promise2 = new Promise((_, reject) => setTimeout(reject, 200, "Failed!"));

Promise.race([promise1, promise2])
  .then(console.log)
  .catch(console.error); // Output: "Failed!"
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

The use of Promise.race() is advantageous in scenarios where the earliest result is paramount, such as fetching data from multiple URLs where the first response is the only one needed. However, consider that if many Promises are involved, ensuring that resources are properly managed can lead to performance degradation.

Use Cases

  • Timeouts: Implementing a cancellation mechanism for Promises can be accomplished using Promise.race().
function fetchWithTimeout(url, timeout) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout!')), timeout))
  ]);
}
Enter fullscreen mode Exit fullscreen mode

2. Promise.allSettled()

Deep Dive

Promise.allSettled(iterable) returns a Promise that resolves after all of the given promises have either resolved or rejected, with an array of objects that each describe the outcome of each Promise.

const promises = [
  Promise.resolve(3),
  new Promise((resolve, reject) => setTimeout(reject, 100, 'Error')),
  Promise.resolve(42)
];

Promise.allSettled(promises).then((results) => {
  console.log(results);
  // Output: [
  //   { status: "fulfilled", value: 3 },
  //   { status: "rejected", reason: "Error" },
  //   { status: "fulfilled", value: 42 }
  // ]
});
Enter fullscreen mode Exit fullscreen mode

Edge Cases

Using allSettled() allows us to collect results of all Promises, including failures. However, developers must be mindful that the order of the results corresponds to the order of the Promises in the input array, regardless of their completion order.

Real-World Use Cases

allSettled() is particularly useful in scenarios like:

  • Logging or reporting all results of multiple data-fetching tasks, regardless of success or failure.
  • Fallback operations where certain outcomes are still useful (like partial data loading).

Performance Considerations

As with Promise.race(), all Promises in the iterable start in parallel. If some tasks are resource-intensive, it could lead to performance bottlenecks. An optimization strategy could involve prioritizing which Promises are run based on expected duration and importance.

3. Promise.any()

Deep Dive

Promise.any(iterable) returns a Promise that resolves as soon as one of the promises in the iterable fulfills, or rejects if no promises in the iterable fulfill (i.e., they all get rejected).

const promiseA = Promise.reject("Error A");
const promiseB = Promise.reject("Error B");
const promiseC = new Promise((resolve) => setTimeout(resolve, 100, "Success C"));

Promise.any([promiseA, promiseB, promiseC])
  .then(console.log) // Output: "Success C"
  .catch(console.error); // This will not execute
Enter fullscreen mode Exit fullscreen mode

Edge Cases

  • If all Promises reject, Promise.any() will return a rejected Promise with an AggregateError, which is a new type of Error object.
Promise.any([Promise.reject("Error 1"), Promise.reject("Error 2")])
  .catch((e) => console.error(e instanceof AggregateError)); // true
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

As with Promise.race(), once a Promise resolves, the remaining Promises continue to execute. Strategies for optimization may include limiting resource distribution or using workers for heavy computations.

Real-World Use Cases

Promise.any() is beneficial in scenarios involving alternate APIs or service calls, where a fallback is acceptable.

async function fetchData(urls) {
  return Promise.any(urls.map(url => fetch(url)));
}
Enter fullscreen mode Exit fullscreen mode

Advanced Implementation Techniques

The real power of Promise combinators can be realized when they are combined and utilized alongside advanced coding techniques:

Sequential Execution with Promise.allSettled and Array.reduce

Sometimes you may want to process Promises sequentially based on the results of previous executions. Using Promise.allSettled combined with Array.reduce, you can elegantly manage such flows.

const tasks = [
  () => Promise.resolve('Task 1 completed'),
  () => Promise.reject('Task 2 failed'),
  () => Promise.resolve('Task 3 completed'),
];

tasks
  .map(task => () => task().catch(e => e))
  .reduce((prev, curr) => prev.then(curr), Promise.resolve())
  .then(result => console.log(result)); // "Task 1 completed", "Task 2 failed", "Task 3 completed"
Enter fullscreen mode Exit fullscreen mode

Handling Timeouts with a Retry Mechanism

If you have an asynchronous function that might timeout, combining Promise.race() with a retry mechanism can be useful:

async function fetchWithRetry(url, retries = 3) {
  const fetchPromise = () => fetch(url);
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout!'), 1000));

  for (let i = 0; i < retries; i++) {
    try {
      const response = await Promise.race([fetchPromise(), timeoutPromise]);
      return response;
    } catch (error) {
      console.error(`Attempt ${i + 1} failed: ${error}`);
      if (i === retries - 1) throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Comparing Promise Combinators to Alternative Approaches

While the Promise API has fundamentally transformed asynchronous programming, alternative approaches such as async/await syntax or utilizing libraries such as RxJS can provide differing advantages.

  • Async/Await: This feature syntactic sugar that utilizes Promises, providing a cleaner way to write sequential asynchronous code.
  async function fetchData() {
    try {
      const data1 = await fetch(url1);
      const data2 = await fetch(url2);
      return [data1, data2];
    } catch (e) {
      console.error('Fetching failed:', e);
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • RxJS: For applications requiring more complex reactive programming patterns, RxJS allows handling asynchronous streams of data via Observables, which can provide more powerful and fine-grained control, albeit at the cost of additional complexity.

Debugging Techniques and Pitfalls

Imagine a scenario where you have multiple Promises that need careful monitoring. Unhandled rejections can lead to unobserved errors in your application. Utilize techniques like:

  • Promise Rejections: Always attach .catch() handlers to your Promises to gracefully manage errors.
const promise = someAsyncFunction()
  .then((result) => processResult(result))
  .catch((error) => console.error('Error occurred:', error));
Enter fullscreen mode Exit fullscreen mode
  • Using Promise Tracker: For debugging purposes, implement a utility that tracks the state of each Promise.
function trackPromise(promise) {
  console.log('Promise started');
  return promise.finally(() => console.log('Promise settled'));
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The advent of Promise combinators such as Promise.race(), Promise.allSettled(), and Promise.any() allows JavaScript developers to manage asynchronous operations effectively and elegantly. Their incorporation into your coding toolkit can significantly improve the clarity, maintainability, and performance of your asynchronous code. By understanding their individual behaviors, edge cases, real-world applicability, and performance implications, developers can leverage these tools to tackle demanding asynchronous patterns with confidence.

For a deep dive into the Promise API, refer to the MDN documentation on promises. For more advanced subjects, the book "JavaScript: The Good Parts" by Douglas Crockford emphasizes function-based composition, which is essential for effectively working with asynchronous patterns in JavaScript.

By understanding and implementing effective Promise combinators, senior developers will be equipped to build scalable, efficient, and resilient applications capable of handling complex asynchronous tasks with ease.

Top comments (0)