tiny-async-pool
tiny-async-pool runs a promise-returning function over a synchronous iterable while keeping no more than a chosen number of tasks in flight. Version 2 returns a lazy async iterator and yields each successful value as soon as that task finishes, so output is in completion order rather than input order. The first rejection throws from iteration. Its implementation uses native promises, async generators, a Set, and Promise.race with no runtime dependencies. It is a concurrency cap, not a persistent queue, rate limiter, scheduler, or cancellation system.
A good tiny choice on pre-Node-24 runtimes when completion-order streaming and fail-fast behavior are exactly right. Use native stream mapping on Node 24+, or p-map and p-limit when ordering, types, cancellation controls, or queue visibility matter.
Use it if
- You need to process an array, Set, Map, string, typed array, or generator with a small fixed concurrency limit
- You want to handle results as they complete instead of waiting for the entire batch
- You prefer a dependency-free CommonJS package with a tiny inspectable implementation
- Fail-fast iteration matches your job and the iterator function can own any timeout or AbortSignal behavior
- You run Node 24 or newer: the project README now recommends the built-in Readable.from(iterable).map(fn, {concurrency}) API instead of installing this package
- You need results in input order: version 2 explicitly yields as promises complete, and its migration guide says version 1 waited for all results instead
- You need cancellation after one task fails: the README promises immediate rejection, but the implementation has no AbortSignal or cancel method, so already-started iterator calls continue
- Your source is an async iterable or an unbounded producer: the implementation uses for...of, not for await...of, and stores each active promise while pulling a synchronous iterable
- You need TypeScript declarations, retries, priorities, pause and resume, per-second rate limits, dynamic concurrency, queue statistics, or backpressure independent of result consumption: none are present in the one-function API
Setup reality
Install tiny-async-pool and import its default from ESM or require it from CommonJS. Version 2.1.0 publishes only a CommonJS main file, no exports map, no TypeScript declarations, and no engines field. The code requires native async generators and for-await syntax, which the README calls its ES9 baseline; version 1 is the documented fallback for an ES6-style API. Calling asyncPool does not eagerly start work because it returns an async generator. Work begins when for await requests the first result. Pass a concurrency number of at least 1, a synchronous iterable, and an iterator function that returns a promise or value. The README states the lower bound, but the source does not validate it, so reject zero, negative, NaN, and non-integer configuration yourself. Results arrive in completion order. If callers need input order, attach indexes and sort after collection, which also means waiting for the whole batch. The iterator function receives the current item and the original iterable as its second argument, not an index. The first thrown error or rejected promise makes iteration throw immediately. Other promises already in the Set are not cancelled and may keep network connections, files, or timers alive; pass an AbortSignal into your own worker when cooperative cancellation matters. Catch inside the worker and return a tagged result if the batch should continue after individual failures. Concurrency is only a maximum number of in-flight tasks. It does not enforce requests per second, spacing, priorities, retries, or remote-service quotas. Slow handling inside the for-await body also affects scheduling because the generator pauses at each yield before it can pull and start the next input; collect or hand off results quickly when keeping the pool full matters. Inputs must be finite synchronous iterables. For Node 24 and newer, the README itself points to Readable.from(iterable).map(fn, {concurrency}), which removes the need for this package and offers native stream composition.
Patterns
Process items with a fixed limitprocess-with-concurrency
import asyncPool from 'tiny-async-pool'
for await (const user of asyncPool(4, userIds, async (id) => {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
})) {
console.log(user)
}At most four workers run at once. Values are yielded in completion order, not userIds order.
Collect completion-ordered resultscollect-all-results
async function asyncPoolAll(concurrency, iterable, iteratorFn) {
const results = []
for await (const result of asyncPool(concurrency, iterable, iteratorFn)) {
results.push(result)
}
return results
}
const results = await asyncPoolAll(3, items, transform)This is the README's version 1 migration shape, but the array remains in completion order under version 2.
Restore input order after concurrent workpreserve-input-order
const indexed = items.map((item, index) => ({item, index}))
const completed = []
for await (const result of asyncPool(4, indexed, async ({item, index}) => ({
index,
value: await transform(item)
}))) {
completed.push(result)
}
const ordered = completed.sort((a, b) => a.index - b.index).map(({value}) => value)Restoring order requires retaining every result and waiting for the pool to finish. Use p-map if ordered mapping is the default you want.
Catch the first pool failurehandle-fail-fast-error
try {
for await (const result of asyncPool(5, items, worker)) {
consume(result)
}
} catch (error) {
console.error('pool failed', error)
}Iteration throws on the first observed rejection. Tasks already started continue unless your worker supports and receives cooperative cancellation.
Return tagged errors instead of stoppingcontinue-after-item-errors
for await (const outcome of asyncPool(4, items, async (item) => {
try {
return {ok: true, item, value: await worker(item)}
} catch (error) {
return {ok: false, item, error}
}
})) {
if (!outcome.ok) console.error(outcome.item, outcome.error)
}Catching inside the iterator prevents fail-fast rejection. Decide whether partial success is safe for the operation before using this pattern.
Give each fetch its own timeoutabort-timed-out-work
for await (const response of asyncPool(4, urls, async (url) => {
return fetch(url, {signal: AbortSignal.timeout(10_000)})
})) {
console.log(response.status)
}The timeout comes from the worker and runtime, not tiny-async-pool. One timeout rejects iteration while other already-started fetches retain their own signals.
Validate configuration before startingvalidate-concurrency
function checkedPool(concurrency, iterable, iteratorFn) {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError('concurrency must be a positive integer')
}
return asyncPool(concurrency, iterable, iteratorFn)
}The README requires concurrency >= 1, but version 2.1.0 source does not enforce it. Validate external configuration yourself.
Process a Set without converting itprocess-set-values
const paths = new Set(['a.json', 'b.json', 'c.json'])
for await (const document of asyncPool(2, paths, readDocument)) {
indexDocument(document)
}Set is a synchronous iterable and is explicitly supported. Duplicate input values have already been removed by Set semantics.
Process key-value entries from a Mapprocess-map-entries
const jobs = new Map([['a', inputA], ['b', inputB]])
for await (const result of asyncPool(2, jobs, async ([key, input]) => {
return [key, await transform(input)]
})) {
console.log(result[0], result[1])
}Map iteration yields [key, value] pairs. The iterator's second argument would be the original Map, not an index.
Use a finite generator as inputprocess-generator-input
function *pages(lastPage) {
for (let page = 1; page <= lastPage; page++) yield page
}
for await (const data of asyncPool(3, pages(20), fetchPage)) {
save(data)
}Synchronous generators work. Async generators do not because version 2.1.0 consumes input with for...of rather than for await...of.
Report progress as tasks completereport-progress
let completed = 0
for await (const result of asyncPool(6, items, worker)) {
completed += 1
onProgress({completed, total: items.length, result})
}Progress advances in completion order. Keep onProgress quick because the generator waits at each yield before scheduling more input.
Use the Node 24 native replacementuse-node-native-pool
import {Readable} from 'node:stream'
for await (const result of Readable.from(items).map(worker, {concurrency: 4})) {
console.log(result)
}The tiny-async-pool README recommends this on Node 24 and newer. Verify ordering and error behavior against your application before migrating.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | You want a reusable concurrency gate around individually scheduled functions with active and pending counts |
| p-map | npm | You want concurrent mapping with ordered results, stop-on-error controls, and current TypeScript support |
| @supercharge/promise-pool | npm | You want a fluent pool with per-item error handling, callbacks, statistics, and dynamic concurrency |
| async | npm | You need a mature collection of queues, cargo batching, retries, priority scheduling, and other callback or promise control-flow tools |