Limit JavaScript Async Batches with a Worker Pool
Loading a list of JSON files with Promise.all(urls.map(loadJson)) is convenient, but the mapping step calls loadJson for every URL immediately. For a large batch, that can create more outstanding work than the application needs.
A small worker pool lets you choose how many operations may be in flight. This example works with promise-based JavaScript functions in a browser or Node.js. It is useful for application-managed downloads and tooling; engine asset loaders may already have their own scheduling rules.
Understand Where Work Starts
Consider this code:
Each fetch call starts work during map. Promise.all combines the resulting promises; it does not place a concurrency limit on the calls that created them. Its output keeps input order, and a rejection rejects the combined promise, as described in the
ECMAScript Promise.all specification
.
To control when work begins, pass the pool ordinary input values and a function it can call later. Passing promises for requests that have already started is too late.
Implement a Fixed Number of Workers
The helper below accepts a dense array, a positive integer limit, and a mapper. Keep the input array unchanged until the returned promise settles.
| |
Each worker claims one index before reaching await. On the same JavaScript agent, another worker cannot interrupt that synchronous index claim. Once the mapper settles, the worker can claim another item. The mapper must return a promise covering the entire operation you want to limit.
Writing to results[index] preserves input order even when operations finish in another order. An empty input returns an empty array without invoking the mapper. A limit larger than the batch creates only as many workers as there are items.
These workers are asynchronous functions sharing one queue, rather than Web Workers or operating-system threads. They overlap waiting operations but do not move CPU-heavy JavaScript onto other cores. The
async function documentation
explains how execution proceeds through an await.
Keep Body Parsing Inside the Limited Operation
Use the helper to fetch a small set of files from your own server. Replace these example paths with existing JSON resources:
| |
Waiting for response.json() keeps the slot occupied through body reading and JSON parsing. Returning the Response instead would release the slot after headers arrive, before the caller consumes the body. HTTP error statuses also need an explicit response.ok check; see the
Fetch guide
.
The example accepts any valid JSON value. Validate each document’s shape separately before using it as game configuration.
Define Failure Behavior Explicitly
This pool stops claiming queued items once a worker observes a failure. It waits for operations already in flight to settle, then rejects with the first observed error. A separate failed boolean matters because JavaScript permits rejection with undefined or another falsy value.
| Situation | Behavior |
|---|---|
| All mapper calls succeed | Resolve with results in input order |
| A mapper throws synchronously | Record the error and stop new scheduling |
| A mapper promise rejects | Stop new scheduling when the rejection is observed |
| Another operation is already running | Wait for it and handle any rejection |
| A running operation never settles | The batch also remains pending |
This is a deliberate wait-before-reject policy. It provides a clear point at which all work started by this batch has settled. It does not undo completed operations or cancel running requests. More work can start before a rejection is observed, so do not depend on an exact number of calls after a remote failure occurs.
If a view needs cancellation or requests need deadlines, implement those in the mapper with the relevant API and an AbortSignal. For operations with side effects, account for partial completion before adding retries.
Verify the Limit and Ordering
Use deferred promises in tests so completion order is controlled without relying on network speed:
- Submit five inputs with a limit of two. Verify that only two mapper calls start initially.
- Resolve the second call first. Verify that exactly one queued call starts, while the first remains pending.
- Finish the remaining calls out of order. The result array must still match the input order, and the maximum number of active calls must remain two.
- Reject one call while another is pending. Verify that queued work stops and that the batch does not reject until the pending call settles.
- Check an empty array, a limit larger than the batch, invalid limits, a synchronous throw, and rejection with
undefined.
A concurrency limit is a cap on active mapper calls, not a requests-per-second quota. Fast calls can still produce a high request rate. Pick a starting limit, measure latency and failures, and add a separate rate limiter when the service requires one.