tiny-async-pool review
tiny-async-pool 2.1.0 applies one async or promise-returning function to a synchronous iterable while capping the number of active calls. It is an async generator, so consumers receive values when tasks finish rather than in input order. The first observed rejection throws out of iteration. Version 2 replaced the 1.x all-results promise with that streaming contract; 2.1.0 tightened one-at-a-time `Promise.race` consumption and covers workers that return plain values. Our browser bundle was 0.8 KB minified and 0.5 KB gzipped. The README now recommends Node 24's native `Readable.map` instead when available.
tiny-async-pool 2.1.0 added only 0.5 KB gzipped and zero audit findings in our install, while yielding results in completion order and leaving failed-task siblings running. Use it for that exact small pre-Node-24 loop; choose native `Readable.map`, p-map, or p-queue when ordering, cancellation, or scheduling policy matters.
We installed it
| Install | ✓ · 0.3s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package |
| Browser | 0.5 KB | gzipped (0.8 KB minified), bundled with esbuild |
| Types | — | no TypeScript types found |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does tiny-async-pool install cleanly?
Yes. In a fresh container with an empty cache, npm install tiny-async-pool finished in 0.3s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does tiny-async-pool add to a browser bundle?
0.5 KB gzipped (0.8 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does tiny-async-pool work with both ESM and CommonJS?
Yes. Both import 'tiny-async-pool' and require('tiny-async-pool') worked in Node 22 in our run. The package is published as CommonJS.
Does tiny-async-pool include TypeScript types?
No type declarations were found in our install, so TypeScript users need their own declarations.
tiny-async-pool or p-limit: which should you use?
p-limit: Choose it for a reusable concurrency gate with active and pending counts around separately scheduled calls. tiny-async-pool 2.1.0 added only 0.5 KB gzipped and zero audit findings in our install, while yielding results in completion order and leaving failed-task siblings running.
When should you not use tiny-async-pool?
Node 24 or newer is the only target. The package README directs those users to Readable.from(items).map(worker, { concurrency }).
Use it if
- A finite array, Set, Map, typed array, string, or synchronous generator needs a small fixed in-flight limit.
- The caller can process outputs as soon as each task finishes and does not require input ordering.
- Failing the iteration on the first worker error matches the batch's transaction or reporting rules.
- A dependency-free 0.5 KB gzip helper is preferable to a queue package with priorities and lifecycle state.
- Node 24 or newer is the only target. The package README directs those users to `Readable.from(items).map(worker, { concurrency })`.
- Output must preserve input order. Version 2 yields completion order, so restoring order requires indexes, buffering, and a final sort.
- The producer is an async iterable or an endless stream. Version 2.1.0 consumes input with synchronous `for...of`.
- A failed task must cancel siblings. The package has no AbortSignal or cancellation API, and work already started keeps running.
- You need retries, priorities, pause/resume, rate-per-second limits, dynamic concurrency, or active and pending counts. The API exposes none of them.
- TypeScript declarations or package exports are required. Our package check found neither in 2.1.0.
Setup reality
We installed tiny-async-pool 2.1.0 in 0.3 seconds. It left one package using 1 MB on disk; its unpacked files total 24 KB. There are zero direct dependencies and zero peer dependencies, and npm audit found zero known vulnerabilities. The package is CommonJS with no exports map. Both require() and ESM import worked in Node 22, but no TypeScript declarations were included.
Calling asyncPool creates an async generator; work starts when the consumer requests its first value. Pass a positive integer concurrency, a synchronous iterable, and a worker. The README states >= 1, yet the 2.1.0 source performs no validation. Check zero, negatives, fractions, and NaN at configuration boundaries. The worker's second argument is the original iterable, not an item index. Add indexes to the input if identity or ordering must survive completion-order output.
The pool stores active promises in a Set and waits on Promise.race at the limit. A rejection escapes immediately, but sibling calls already running receive no stop signal. Put AbortSignal, timeouts, and cleanup inside the worker when external operations must end. Catch per-item errors inside that worker and return tagged results if partial success is allowed. Slow processing in the for await body also pauses the generator before it can pull and schedule more input.
Our esbuild result was 0.8 KB minified and 0.5 KB gzipped, so weight is almost irrelevant. Semantics decide the install. Version 1 collected everything; version 2.1.0 streams completion order. It limits simultaneous promises, not requests per second or remote quota. On Node 24, the README recommends the stable stream mapping API. Elsewhere, p-map fits ordered mapping, p-limit fits a reusable gate, and p-queue fits scheduling that needs state or priorities.
Patterns
Run at most 4 workers limit-concurrency
import asyncPool from 'tiny-async-pool'
for await (const profile 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()
})) {
save(profile)
}At most 4 calls run together, and profiles arrive in completion order rather than `userIds` order.
Build an array from the async iterator collect-results
async function collectPool(limit, items, worker) {
const values = []
for await (const value of asyncPool(limit, items, worker)) {
values.push(value)
}
return values
}
const values = await collectPool(3, items, transform)The collected array still follows 2.1.0 completion order; this wrapper does not recreate the ordered 1.x result.
Sort tagged results back into input order restore-input-order
const tagged = items.map((item, index) => ({ item, index }))
const done = []
for await (const value of asyncPool(4, tagged, async ({ item, index }) => ({
index,
output: await transform(item),
}))) {
done.push(value)
}
const ordered = done.sort((a, b) => a.index - b.index).map((x) => x.output)Ordering forces the caller to retain all results and wait for completion; p-map is simpler when this is the default requirement.
Handle the first rejected worker catch-pool-error
try {
for await (const result of asyncPool(5, items, worker)) {
consume(result)
}
} catch (error) {
console.error('pool stopped yielding', error)
}The iteration throws, but 2.1.0 does not cancel promises that were already added to its active Set.
Return per-item outcomes continue-after-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) report(outcome)
}Catching inside the worker prevents fail-fast behavior; only use partial success when downstream state can tolerate it.
Bound each fetch separately timeout-worker
for await (const response of asyncPool(4, urls, (url) => {
return fetch(url, { signal: AbortSignal.timeout(10_000) })
})) {
console.log(response.status)
}The 10-second signal belongs to each fetch. tiny-async-pool supplies no shared timeout or cancellation controller.
Reject an invalid concurrency setting validate-limit
function checkedPool(limit, items, worker) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError('limit must be a positive integer')
}
return asyncPool(limit, items, worker)
}Version 2.1.0 documents a minimum of 1 but does not enforce it in source.
Consume key-value pairs directly process-map
const jobs = new Map([['invoice', a], ['receipt', b]])
for await (const [name, output] of asyncPool(2, jobs, async ([name, input]) => {
return [name, await render(input)]
})) {
console.log(name, output)
}Map yields `[key, value]` pairs; the worker's second parameter would be the original Map, not a numeric index.
Pull from a finite synchronous generator process-generator
function* pages(total) {
for (let page = 1; page <= total; page += 1) yield page
}
for await (const data of asyncPool(3, pages(20), fetchPage)) {
store(data)
}Synchronous generators work in 2.1.0. Async generators do not because input is consumed with `for...of`.
Use Node 24's native mapper use-node-stream-map
import { Readable } from 'node:stream'
for await (const result of Readable.from(items).map(worker, { concurrency: 4 })) {
consume(result)
}The project README recommends this path on Node 24 and newer, removing the package dependency.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | Choose it for a reusable concurrency gate with active and pending counts around separately scheduled calls. |
| p-map | npm | Choose it for typed concurrent mapping when input order and richer error controls matter. |
| p-queue | npm | Choose it when priorities, pause and resume, interval limits, and queue observability are required. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

