Rate-Limit Requests with Lodash chunk and Promise.all

Rate-limiting network requests is essential when interacting with APIs to avoid encountering HTTP 429 (Too Many Requests) errors or exhausting client and server resources. While executing network requests concurrently with Promise.all is fast, sending hundreds or thousands of simultaneous calls can easily crash a service. By pairing Lodash's _.chunk function with sequential iteration and Promise.all, you can batch requests into manageable groups, control concurrency limits, and safely process high volumes of network calls.

The Problem with Unrestricted Concurrency

A standard approach to handling multiple requests is mapping an array of items directly into Promise.all:

// Risky for large datasets: fires all requests simultaneously
await Promise.all(urls.map(url => fetch(url)));

If the urls array contains 1,000 items, the browser or Node.js environment will attempt to open 1,000 network connections at the same time. This behavior leads to socket exhaustion, memory spikes, and aggressive rate-limiting by target servers.

How _.chunk Solves the Problem

The _.chunk function from Lodash splits an array into smaller groups of a specified size:

import _ from 'lodash';

const items = [1, 2, 3, 4, 5];
const batches = _.chunk(items, 2);
// Result: [[1, 2], [3, 4], [5]]

When applied to asynchronous workflows, each sub-array represents a single batch. You can process the items inside a chunk concurrently with Promise.all, but await the completion of the entire chunk before moving to the next.

Implementing the Rate-Limiting Pattern

To implement this pattern, split your list of tasks using _.chunk and use a standard for...of loop to iterate through each batch sequentially.

import _ from 'lodash';

async function fetchInBatches(items, batchSize) {
  // 1. Split the entire array into chunks of `batchSize`
  const chunks = _.chunk(items, batchSize);
  const results = [];

  // 2. Iterate through each chunk one after the other
  for (const chunk of chunks) {
    // 3. Process all requests within the current chunk in parallel
    const chunkResults = await Promise.all(
      chunk.map(async (item) => {
        const response = await fetch(`https://api.example.com/data/${item}`);
        return response.json();
      })
    );

    // 4. Aggregate results
    results.push(...chunkResults);
  }

  return results;
}

In this implementation, if batchSize is set to 5, exactly five requests are fired at once. The function pauses execution until all five requests in the batch resolve (or reject), and only then does it initiate the next batch of five.

Adding a Delay Between Batches

Some APIs enforce limits based on time intervals, such as a maximum of 10 requests per second. To satisfy this requirement, introduce a sleep utility between iterations:

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function fetchWithDelay(items, batchSize, delayMs) {
  const chunks = _.chunk(items, batchSize);
  const results = [];

  for (let i = 0; i < chunks.length; i++) {
    const chunkResults = await Promise.all(
      chunks[i].map((item) => fetch(`/api/resource/${item}`).then(res => res.json()))
    );

    results.push(...chunkResults);

    // Pause between batches, excluding after the final batch
    if (i < chunks.length - 1) {
      await sleep(delayMs);
    }
  }

  return results;
}

Error Handling Considerations

Because Promise.all rejects immediately if any single promise fails (fail-fast behavior), an error in one request could cancel the processing of the entire batch. If you want every request to settle regardless of failures, substitute Promise.all with Promise.allSettled:

const chunkResults = await Promise.allSettled(
  chunk.map((item) => fetch(`/api/resource/${item}`).then(res => res.json()))
);

Using _.chunk alongside Promise.all or Promise.allSettled provides a lightweight, dependency-minimal solution for managing concurrency and adhering to API rate limits without needing complex queueing libraries.