Automation · · 6 min read
p-limit: control how many AI enrichment jobs run at once
Run a bounded enrichment batch, retain an outcome for every lead and verify that failures do not erase the rest of the results.
By Sociologix Editorial

A batch needs a visible work limit
An AI enrichment worker might receive five leads together, then later receive five hundred. Starting every lookup immediately can overwhelm a dependency or create more simultaneous work than your application can observe comfortably. A small explicit concurrency limit gives that batch a predictable shape.
This AI-assisted editorial guide uses p-limit with synthetic enrichment jobs. The original example was executed locally at caps of one and two, including one deliberate rejection. It checks the highest number of active jobs, an outcome for every input and clean completion. It makes no AI-provider calls and measures no model accuracy, provider throughput or business results.
1. Install a pinned package in a scratch folder
The maintainer’s package is sindresorhus/p-limit, published as p-limit on npm under the MIT license. The checked version is 7.3.3, with Node.js 20 or newer required and ES module packaging. The local run used Node.js 24.19.0 on October 19, 2026. [1]
Run the first three PowerShell commands, save both JavaScript blocks below in order as concurrency-demo.mjs, then run the final command. Installation needs internet access; the fixtures themselves use only local timers and require no provider account or credentials.
mkdir ai-concurrency-demo
cd ai-concurrency-demo
npm install --ignore-scripts "p-limit@7.3.3"
# Save both JavaScript blocks below as concurrency-demo.mjs first.
node concurrency-demo.mjs2. Schedule the function before it starts
pLimit(concurrency) creates a limiter. Pass a deferred function such as limit(() => enrich(lead)) so the operation starts when a slot becomes available. Return the whole job’s promise; detached work can continue after the limiter believes the job has finished. activeCount and pendingCount expose running and waiting work. [2]
Our five fixtures have different delays and stable demo IDs. One throws deliberately. The finally block always releases our measurement counter, including on rejection. These counters observe this example; they are separate from the library’s internal scheduling.
import assert from "node:assert/strict";
import { setTimeout as sleep } from "node:timers/promises";
import pLimit from "p-limit";
const leads = [
{ id: "demo-1", delay: 40 },
{ id: "demo-2", delay: 5 },
{ id: "demo-3", delay: 15, fail: true },
{ id: "demo-4", delay: 10 },
{ id: "demo-5", delay: 5 },
];
async function runBatch(concurrency) {
const limit = pLimit(concurrency);
let active = 0, peak = 0;
const completed = [];
async function enrich(lead) {
active++;
peak = Math.max(peak, active);
try {
await sleep(lead.delay);
if (lead.fail) throw new Error("Synthetic enrichment failure");
return { leadId: lead.id, segment: "Demo segment" };
} finally {
completed.push(lead.id);
active--;
}
}
// The callback defers execution until a slot is available.
const outcomes = await Promise.allSettled(
leads.map(lead => limit(() => enrich(lead))),
);
const rows = outcomes.map((outcome, index) => ({
leadId: leads[index].id,
status: outcome.status,
segment: outcome.status === "fulfilled" ? outcome.value.segment : null,
}));
return { rows, peak, active, completed,
remaining: limit.activeCount + limit.pendingCount };
}3. Keep one outcome for each input
Promise.allSettled waits for every submitted promise and returns outcomes in input order, regardless of when jobs finish. The example uses each outcome’s index to attach the original lead ID, then records fulfilled or rejected status. A failed enrichment receives a null segment rather than a made-up classification. [3]
Append these assertions. The cap-two run also checks that completion order differs from input order, while the final rows remain aligned with the original IDs. No retry is performed, so each fixture represents one attempt.
// Local synthetic tests; no AI provider, CRM or network call.
for (const cap of [1, 2]) {
const result = await runBatch(cap);
assert.equal(result.peak, cap);
assert.equal(result.active, 0);
assert.equal(result.remaining, 0);
assert.deepEqual(result.rows.map(row => row.leadId), leads.map(lead => lead.id));
assert.deepEqual(result.rows.map(row => row.status), [
"fulfilled", "fulfilled", "rejected", "fulfilled", "fulfilled",
]);
assert.equal(result.rows[2].segment, null);
assert.equal(result.completed.length, leads.length);
if (cap === 2) assert.notDeepEqual(result.completed, leads.map(lead => lead.id));
console.log(`cap=${cap}: peak=${result.peak}; 4 fulfilled, 1 rejected; input order preserved`);
}4. Check the observed behavior
Both local batches completed successfully. With cap one, peak active work was one. With cap two, it was two. Each batch retained five rows: four fulfilled and one rejected. The failed row had a null segment, every job reached its finally block, and both running and queued counts ended at zero.
This establishes the tested scheduling behavior for these fixtures. It does not establish a suitable production cap. Start with a modest value based on the dependency and measure queue wait, timeouts and failure counts in a controlled environment before increasing it. Retain failed lead IDs for a separately reviewed retry queue.
5. Add the limits this small queue does not provide
A concurrency cap controls overlap, not requests per minute or tokens per minute. Many fast jobs can start within one minute even with a small cap. The maintainer’s related p-throttle package uses an interval and call count for time-based throttling; select a policy that matches the actual provider quota rather than treating concurrency as a substitute. [4]
Each pLimit call creates its own queue and active counter. From that implementation, a limit shared by one process does not coordinate other instances or serverless replicas. Use shared coordination when a ceiling must cover the whole deployment. This example’s waiting jobs are also held in memory, so it provides no restart recovery. [5]
Keep input batches bounded: mapping a very large source creates all those pending promises in memory. Real requests still need timeouts, because a job that never settles can hold a slot and prevent the batch from completing. Do not nest the same limiter inside its own limited task; the maintainer warns that this can deadlock. [2]
Before connecting a CRM write, validate the returned fields and decide which failures need human review. An enrichment completing successfully means its promise resolved; your business still needs rules for whether its content is approved for use.
Sources & further reading
Give your AI worker a manageable workload
Sociologix can help design AI enrichment and automation pipelines with concurrency limits, failure queues, validation and CRM handoffs. Bring one batch workflow and its provider constraints.
Talk to Sociologix