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.