
Limit JavaScript jobs before they start
In short: Pass deferred job functions into a concurrency limiter; wrapping promises after work starts cannot enforce the intended in-flight limit.
javascript ▸ import assert from 'node:assert/strict';
import {setTimeout as delay} from 'node:timers/promises';
import pLimit from 'p-limit';
const limit = pLimit(2);
let active = 0, peak = 0;
async function work(id) {
active++;
peak = Math.max(peak, active);
try {
await delay(10);
return id;
} finally {
active--;
}
}
const ids = [0, 1, 2, 3, 4, 5];
const jobs = ids.map(id => limit(() => work(id)));
assert.deepEqual(await Promise.all(jobs), ids);
assert.equal(peak, 2);
assert.equal(active, 0);
console.log('six completed; peak active: two');The concurrency limit said two. Six jobs were active. A synthetic check on 15 October 2026 reproduced that mismatch with Node.js 22.15.0 and p-limit 3.1.0: every job had already started before its promise reached the limiter.
Limit JavaScript jobs before they start by passing the work as a callback. My review rule is to count entry into the operation that consumes the resource, not merely the wrappers waiting for it. A queue can look perfectly bounded while the work behind it is already flooding the destination.
Limit JavaScript jobs at the function call
The p-limit API accepts a promise-returning function. That function is the delayed part. In limit(() => work(id)), the limiter decides when work gets called.
Compare that with const promises = ids.map(work). For an ordinary async function, each call begins executing before returning its promise. Putting limit(() => promise) around those existing promises afterwards cannot rewind the calls. It schedules waiting, not the work you intended to restrict.
Promise.all then collects the outcomes. Its result array follows input order rather than completion order. Neither that ordering nor the presence of an await establishes a cap on how many operations have started.
The synthetic p-limit check
Save this as limit-demo.mjs in a project with p-limit installed and run node limit-demo.mjs. This exact example uses synthetic timers, not a network endpoint. It was tested against the existing 3.1.0 dependency; that is a reproducibility detail, not a recommendation to downgrade your package.
import assert from 'node:assert/strict';
import {setTimeout as delay} from 'node:timers/promises';
import pLimit from 'p-limit';
const limit = pLimit(2);
let active = 0, peak = 0;
async function work(id) {
active++;
peak = Math.max(peak, active);
try {
await delay(10);
return id;
} finally {
active--;
}
}
const ids = [0, 1, 2, 3, 4, 5];
const jobs = ids.map(id => limit(() => work(id)));
assert.deepEqual(await Promise.all(jobs), ids);
assert.equal(peak, 2);
assert.equal(active, 0);
console.log('six completed; peak active: two');
The counter lives inside work, before its first wait. That placement is the test. Counting only limiter callbacks would miss operations started elsewhere.
The comparison harness exercised all three arrangements:
| Arrangement for six jobs | Peak active jobs | Completed jobs |
|---|---|---|
| Call every job immediately | 6 | 6 |
| Start jobs, then wrap their promises | 6 | 6 |
| Let the limiter invoke each job | 2 | 6 |
Local Node.js 22.15.0 and p-limit 3.1.0 behavioural checks; no throughput benchmark.
An empty-input check also passed. In a separate failure case, one job rejected while the other five still completed. A successful concurrency assertion therefore says nothing about stopping the batch after an error.
My judgement for a retrieval fan-out
For a hypothetical agent querying several retrieval sources, I would put each complete request inside the callback. Don't create the fetch first and then hand its promise to the limiter. Keep the slot occupied until the result you need has been consumed, not just until headers arrive—the separate fetch body timeout check explains that completion boundary.
Choose the limiter's ownership deliberately. A fresh limiter per request gives each request its own allowance. It does not establish one shared allowance across requests, processes or machines. No real agent deployment or provider quota was tested here.
There is also no requests-per-minute guarantee. Short operations can enter and leave a small concurrency window rapidly. Treat provider rate quotas, retry policy and cancellation as separate controls. The documented clearQueue operation does not cancel running promises, so it is not an emergency brake for work already underway.
What's in it for you
- Move the actual operation inside the limiter callback, then instrument its entry and exit.
- Keep a failing-job fixture alongside the peak-concurrency assertion so the batch's failure policy stays explicit.
A concurrency limit must own the start of the work, not just the wait for its result.
Sources
- Sindre Sorhus — p-limit API, version 3.1.0, accessed 15 October 2026.
- MDN — Promise.all, accessed 15 October 2026.


