promise-limit
promise-limit is a tiny CommonJS semaphore for promise-returning work. Create one limiter with a concurrency count, pass it zero-argument functions, and it starts no more than that count at once while preserving submission order. It also includes a limited map helper and exposes the number of waiting jobs. There are no runtime dependencies, no scheduler configuration, and no cancellation layer.
A readable, dependency-free limiter for legacy CommonJS code, but its frozen feature set and missing types make p-limit the better default for new work. Keep it when four tiny methods are exactly the contract you need.
Use it if
- You maintain CommonJS code and need a minimal concurrency gate around an existing promise-returning function
- You want queued tasks to start in the same order they were submitted
- You need a simple queue-length signal so a producer can pause before pending work consumes too much memory
- You support older Node.js code where a dependency-free, pre-ESM limiter is easier to introduce than a modern ESM-only package
- You want an actively evolving package: version 2.7.0 was published in 2022 and the repository's last push was also in 2022
- You use TypeScript and expect bundled declarations: the published package has no types or typings entry
- You need cancellation, timeouts, priorities, rate limits, pause or queue clearing; the public API only limits concurrency, maps arrays, and reports queue length
- Your mapper can return a plain value: limit.map calls .catch directly on the mapper result, so its documented promise-returning contract is real and synchronous values can fail
- You need graceful all-results error handling: limit.map rejects on the first failure and deliberately prevents queued mappers from running, with no allSettled mode
Setup reality
Installation is only npm install promise-limit, and version 2.7.0 has no runtime dependencies, native build, peer dependency, credentials, or config file. The package is CommonJS, so the documented shape is const promiseLimit = require('promise-limit'); TypeScript users must supply their own declaration or install a community declaration if one fits. The limiter accepts functions, not already-started promises. Calling limit(fetch(url)) starts the work before the limiter can control it and passes the wrong value; wrap it as limit(() => fetch(url)). A count of 0 or undefined means unlimited execution rather than a stopped queue. There is no input validation, timeout, AbortSignal support, priority, queue clearing, or explicit shutdown. A task releases its slot when its returned promise settles, and synchronous throws are converted to rejections. limit.map has a sharper edge: the mapper is expected to return an actual promise because the implementation calls .catch on it, and after one mapper rejects, work still waiting in the queue is skipped. The queue property counts waiting jobs only, not running jobs. If producers can outrun consumers, you must watch that value and apply backpressure yourself.
Patterns
Run at most three jobs at oncelimit-concurrency
const promiseLimit = require('promise-limit')
const limit = promiseLimit(3)
const results = await Promise.all(
urls.map((url) => limit(() => fetchJson(url)))
)Pass a function that starts the work. Passing an existing promise cannot prevent that promise from already running.
Serialize promise-returning tasksrun-serially
const limit = require('promise-limit')(1)
await Promise.all([
limit(() => writeRecord('first')),
limit(() => writeRecord('second')),
limit(() => writeRecord('third'))
])A concurrency of 1 starts tasks in submission order, but Promise.all still rejects as soon as any submitted task rejects.
Capture task arguments in a closurepass-arguments
const limit = require('promise-limit')(4)
function limitedLookup(id, locale) {
return limit(() => lookupUser(id, locale))
}
const user = await limitedLookup(42, 'en')The limiter calls its function with no arguments, so close over every value the task needs.
Map an array with bounded concurrencymap-with-limit
const limit = require('promise-limit')(5)
const pages = await limit.map(ids, (id, index) => {
return fetchPage(id).then((page) => ({ index, page }))
})The mapper must return a promise. The implementation calls .catch on its result rather than normalizing a plain value first.
Keep results aligned with inputspreserve-result-order
const limit = require('promise-limit')(2)
const inputs = ['slow', 'fast', 'medium']
const outputs = await Promise.all(
inputs.map((value) => limit(() => transform(value)))
)
// outputs use input order, even if completion order differsPromise.all preserves the order of its input promises; the limiter also starts queued work in submission order.
Handle each limited task failurehandle-task-errors
const limit = require('promise-limit')(3)
const result = await limit(() => callService()).catch((error) => {
console.error('service call failed', error)
return null
})A rejected task releases its slot before the returned promise rejects, so later queued work can continue.
Let a synchronous throw become a rejectionconvert-sync-throw
const limit = require('promise-limit')(2)
try {
await limit(() => {
throw new Error('bad input')
})
} catch (error) {
console.error(error.message)
}The implementation catches synchronous exceptions and returns a rejected promise while correctly releasing the slot.
Inspect the number of waiting jobsobserve-queue
const limit = require('promise-limit')(2)
for (const item of items) {
pending.push(limit(() => processItem(item)))
if (limit.queue > 100) console.warn('producer is outrunning workers')
}
await Promise.all(pending)limit.queue excludes currently running jobs and only reports tasks that have not started.
Pause a producer when the queue growsapply-backpressure
const limit = require('promise-limit')(4)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
for await (const record of source) {
while (limit.queue >= 50) await sleep(25)
void limit(() => save(record)).catch(reportError)
}The package does not cap its queue or pause producers automatically; this polling is application-level backpressure.
Select unlimited mode explicitlydisable-limit
const promiseLimit = require('promise-limit')
const limit = promiseLimit(shouldThrottle ? 8 : 0)
await Promise.all(tasks.map((task) => limit(task)))Both 0 and undefined select the pass-through implementation. They do not create a queue with zero available slots.
Understand map failure behaviorstop-map-on-error
const limit = require('promise-limit')(2)
try {
await limit.map(records, (record) => validateAndSave(record))
} catch (error) {
console.error('mapping stopped', error)
}After the first rejection, limit.map rejects and queued mapper calls are skipped. Already-running calls cannot be cancelled.
Collect outcomes without limit.map stopping earlycollect-all-outcomes
const limit = require('promise-limit')(3)
const outcomes = await Promise.all(
jobs.map((job) => limit(async () => {
try {
return { status: 'fulfilled', value: await run(job) }
} catch (reason) {
return { status: 'rejected', reason }
}
}))
)Wrap errors inside each task when every job must run; limit.map intentionally stops scheduling mapper calls after a failure.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | Modern projects that want TypeScript declarations, active maintenance, mutable concurrency, and queue controls |
| p-map | npm | Array mapping with concurrency, AbortSignal support, and configurable stop-on-error behavior |
| bottleneck | npm | API clients that need rate windows, priorities, reservoirs, retries, or distributed coordination |