Understanding Promise.all and the Power of JavaScript Promises

Thamizh ArasanThamizh Arasan
7 min read

In the world of modern JavaScript development, handling asynchronous operations efficiently is crucial for building responsive and performant applications. At the heart of JavaScript's asynchronous programming model lies the Promise API, with Promise.all serving as one of its most powerful features. Let's dive deep into what promises are and how Promise.all can transform the way you handle concurrent operations.

What Are JavaScript Promises?

A Promise in JavaScript represents a value that might not be available yet. It's essentially an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Think of it as a placeholder for a future value.

Before promises, asynchronous operations were primarily handled with callbacks, which often led to deeply nested code structures affectionately known as "callback hell":

getData(function(data) {
  processData(data, function(processedData) {
    displayData(processedData, function(result) {
      console.log("Finally done!", result);
    }, errorCallback);
  }, errorCallback);
}, errorCallback);

Promises introduced a more elegant approach:

getData()
  .then(data => processData(data))
  .then(processedData => displayData(processedData))
  .then(result => console.log("Finally done!", result))
  .catch(error => console.error("Something went wrong:", error));

The Anatomy of a Promise

A Promise can be in one of three states:

  1. Pending: Initial state, neither fulfilled nor rejected

  2. Fulfilled: Operation completed successfully

  3. Rejected: Operation failed

Creating a promise is straightforward:

const myPromise = new Promise((resolve, reject) => {
  // Asynchronous operation here
  if (/* operation successful */) {
    resolve(value); // Promise fulfilled
  } else {
    reject(error); // Promise rejected
  }
});

Once created, you can handle the promise's result using .then() and .catch() methods:

myPromise
  .then(value => {
    // Handle success
  })
  .catch(error => {
    // Handle error
  });

Enter Promise.all: Handling Multiple Promises Concurrently

While individual promises are powerful, real-world applications often require managing multiple asynchronous operations simultaneously. This is where Promise.all shines.

Promise.all takes an iterable of promises and returns a single promise that resolves when all of the input promises have resolved, or rejects if any of the input promises reject.

The Syntax

Promise.all([promise1, promise2, promise3])
  .then(results => {
    // Handle results
  })
  .catch(error => {
    // Handle error
  });

A Practical Example: Fetching Multiple API Endpoints

Let's say you're building a dashboard that needs data from three different API endpoints:

function fetchUserData(userId) {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json());
}

function fetchUserPosts(userId) {
  return fetch(`/api/users/${userId}/posts`)
    .then(response => response.json());
}

function fetchUserFollowers(userId) {
  return fetch(`/api/users/${userId}/followers`)
    .then(response => response.json());
}

// Without Promise.all (sequential)
function loadDashboardSequentially(userId) {
  let dashboard = {};

  return fetchUserData(userId)
    .then(userData => {
      dashboard.user = userData;
      return fetchUserPosts(userId);
    })
    .then(posts => {
      dashboard.posts = posts;
      return fetchUserFollowers(userId);
    })
    .then(followers => {
      dashboard.followers = followers;
      return dashboard;
    });
}

// With Promise.all (parallel)
function loadDashboardParallel(userId) {
  const userDataPromise = fetchUserData(userId);
  const postsPromise = fetchUserPosts(userId);
  const followersPromise = fetchUserFollowers(userId);

  return Promise.all([userDataPromise, postsPromise, followersPromise])
    .then(([userData, posts, followers]) => {
      return {
        user: userData,
        posts: posts,
        followers: followers
      };
    });
}

With Promise.all, all three API calls run concurrently rather than sequentially, potentially saving significant time if each request takes 500ms:

  • Sequential approach: ~1500ms (500ms + 500ms + 500ms)

  • Parallel approach with Promise.all: ~500ms (the time of the slowest request)

Key Characteristics of Promise.all

  1. All or Nothing: If any promise in the array rejects, the entire Promise.all rejects immediately with that error, discarding all other results.

  2. Order Preservation: The returned array of results maintains the same order as the input promises, regardless of which promise resolves first.

  3. Empty Array Handling: If you pass an empty array, Promise.all([]) resolves immediately with an empty array.

  4. Non-Promise Handling: If any item in the input array isn't a promise, it's treated as an already resolved promise.

When Promise.all Falls Short: Enter Promise.allSettled

One limitation of Promise.all is its "fail fast" behavior – if any promise rejects, the entire operation fails. In many scenarios, you might want to know the outcome of all promises, regardless of whether some fail.

This is where Promise.allSettled (introduced in ES2020) comes in:

Promise.allSettled([promise1, promise2, promise3])
  .then(results => {
    // results is an array of objects with status and value/reason
    results.forEach(result => {
      if (result.status === 'fulfilled') {
        console.log('Success:', result.value);
      } else {
        console.log('Failed:', result.reason);
      }
    });
  });

Each result object has:

  • A status property: either "fulfilled" or "rejected"

  • A value property (if fulfilled) or a reason property (if rejected)

Other Promise Combinators

Beyond Promise.all and Promise.allSettled, JavaScript offers other ways to combine promises:

Promise.race

Returns a promise that fulfills or rejects as soon as one of the input promises fulfills or rejects:

Promise.race([
  fetch('/endpoint-1'),
  fetch('/endpoint-2')
])
.then(response => {
  // Process the first response to arrive
});

This is useful for implementing timeouts or taking the result from whichever source responds first.

Promise.any

Introduced in ES2021, Promise.any returns a promise that resolves with the value from the first promise that fulfills, ignoring any rejections unless all promises reject:

Promise.any([
  fetch('/endpoint-may-fail-1'),
  fetch('/endpoint-may-fail-2'),
  fetch('/endpoint-may-fail-3')
])
.then(firstSuccessfulResponse => {
  // Process the first successful response
})
.catch(aggregateError => {
  // All promises rejected
  console.log(aggregateError.errors); // Array of rejection reasons
});

Real-World Scenarios for Promise.all

Scenario 1: Data Aggregation

When building a dashboard that needs data from multiple sources:

Promise.all([
  fetch('/api/sales').then(r => r.json()),
  fetch('/api/inventory').then(r => r.json()),
  fetch('/api/customers').then(r => r.json())
])
.then(([sales, inventory, customers]) => {
  buildDashboard(sales, inventory, customers);
})
.catch(error => {
  showErrorMessage("Failed to load dashboard data");
  console.error(error);
});

Scenario 2: Batch Operations

When you need to save multiple items to a database:

const saveAllProducts = products => {
  const savePromises = products.map(product => 
    fetch('/api/products', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(product)
    }).then(response => response.json())
  );

  return Promise.all(savePromises);
};

saveAllProducts(newProducts)
  .then(results => {
    console.log(`Successfully saved ${results.length} products`);
  })
  .catch(error => {
    console.error("Failed to save some products:", error);
  });

Scenario 3: Dependency Resolution

When you need to load resources with dependencies:

function loadApplication() {
  // First load all core resources in parallel
  return Promise.all([
    loadConfig(),
    loadTranslations(),
    loadUserData()
  ])
  .then(([config, translations, userData]) => {
    // Then initialize the app with the loaded resources
    return initializeApp(config, translations, userData);
  })
  .then(() => {
    // Finally, load non-critical resources
    return Promise.all([
      loadAnalytics(),
      loadAdditionalModules()
    ]);
  });
}

Performance Considerations

While Promise.all is powerful, there are important performance considerations:

  1. Memory Usage: When dealing with a large number of promises, be mindful of memory consumption. Each promise and its result are held in memory until all promises resolve.

  2. Concurrency Limits: Browsers and servers have limits on concurrent connections. For a large number of network requests, consider batching.

  3. Error Handling Strategy: Decide whether to use Promise.all (fail fast) or Promise.allSettled (collect all results) based on your error handling requirements.

  4. Cancellation: Native promises don't support cancellation. For long-running operations that might need to be canceled, consider using the AbortController API or libraries with cancellation support.

Advanced Patterns with Promise.all

Batching Large Operations

When dealing with hundreds or thousands of operations, you might want to process them in batches:

async function processManyItemsInBatches(items, batchSize = 50) {
  const results = [];

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchPromises = batch.map(item => processItem(item));

    // Process each batch with Promise.all
    const batchResults = await Promise.all(batchPromises);
    results.push(...batchResults);

    // Optional: Add a delay between batches
    if (i + batchSize < items.length) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
  }

  return results;
}

Implementing Retry Logic

For unreliable operations, you might want to combine Promise.all with retry logic:

function fetchWithRetry(url, options = {}, retries = 3, delay = 300) {
  return fetch(url, options)
    .catch(error => {
      if (retries <= 0) throw error;

      return new Promise(resolve => setTimeout(resolve, delay))
        .then(() => fetchWithRetry(url, options, retries - 1, delay * 2));
    });
}

// Use with Promise.all
Promise.all([
  fetchWithRetry('/api/endpoint1'),
  fetchWithRetry('/api/endpoint2'),
  fetchWithRetry('/api/endpoint3')
])
.then(responses => Promise.all(responses.map(r => r.json())))
.then(data => {
  // Process data
});

Conclusion: The Promise of Better Asynchronous Code

JavaScript promises, and especially Promise.all, have revolutionized how we handle asynchronous operations. They provide a cleaner syntax, better error handling, and more efficient execution compared to traditional callback patterns.

By mastering Promise.all and understanding when to use alternative combinators like Promise.allSettled, Promise.race, and Promise.any, you can write more efficient, maintainable, and robust asynchronous code.

Whether you're fetching data from multiple APIs, processing batches of operations, or coordinating complex workflows, promises offer the tools you need to handle modern JavaScript's asynchronous nature elegantly and effectively.

Remember, the true power of promises isn't just in avoiding callback hell—it's in giving you precise control over how concurrent operations are executed and combined, helping you build faster and more responsive applications.

0
Subscribe to my newsletter

Read articles from Thamizh Arasan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thamizh Arasan
Thamizh Arasan