p-map
p-map runs an async function over a list of inputs with a concurrency cap. Promise.all fires everything at once and Promise.allSettled hides which input failed; pMap(items, mapper, {concurrency: 5}) keeps at most five mappers in flight, returns results in input order, and gives you real choices about errors: fail fast, collect everything into an AggregateError, or skip bad items with pMapSkip. pMapIterable does the same as a streaming async iterable with backpressure, so results are consumed as they arrive instead of after the whole batch. Zero dependencies, a couple of KB.
The cleanest way to do bounded-concurrency async mapping in Node, and at about 1 KB with zero dependencies there is little reason to hand-roll it. Just remember it is a batch primitive, not a rate limiter or a job queue, and that CJS projects cannot use current versions.
Use it if
- You are hammering an API or database with hundreds of async calls and need 'at most N at a time' without writing a queue: this is the exact problem p-map solves
- You want failures aggregated instead of losing them: stopOnError: false waits for everything and throws one AggregateError listing every rejection
- You need cancellation mid-batch: the signal option wires an AbortController through the whole map
- You consume results slower than you produce them: pMapIterable's backpressure option stops the mappers from racing ahead of the consumer
- Your input is small and failures should abort everything anyway: Promise.all(items.map(fn)) is built in and needs no dependency
- You need a durable job queue with retries, priorities, timeouts, or persistence: p-map is an in-memory one-shot batch; p-queue covers scheduling, BullMQ covers real queues
- You need to limit calls per second rather than calls in flight: concurrency is not rate limiting, and the README itself tells you to compose with p-throttle for that
- You are stuck on CommonJS: like the rest of the sindresorhus catalog it is ESM only (v7 needs Node 18+), so require('p-map') fails and CJS projects are pinned to the old v4
Setup reality
npm install p-map, one import, zero dependencies, bundled TypeScript types; there is genuinely nothing else to set up. The traps are semantic rather than mechanical. It is ESM only, so CommonJS callers get ERR_REQUIRE_ESM and old tutorials pointing at v4 still circulate. With stopOnError: true (the default) already-started mappers keep running after the rejection propagates, and with infinite default concurrency over a sync iterable every mapper starts immediately, so an unbounded pMap behaves like Promise.all plus surprises; in practice you should always pass concurrency. AggregateError handling also means checking error.errors, which teams forget in catch blocks.
Patterns
Map with a concurrency caplimit-concurrency
import pMap from 'p-map';
const results = await pMap(
urls,
async url => {
const response = await fetch(url);
return response.json();
},
{concurrency: 5}
);ESM only: require('p-map') throws in CommonJS. Results come back in input order regardless of completion order. Default concurrency is Infinity, so always set it; otherwise you have rebuilt Promise.all.
Run everything and aggregate failurescollect-all-errors
import pMap from 'p-map';
try {
await pMap(jobs, runJob, {concurrency: 4, stopOnError: false});
} catch (error) {
// AggregateError: every rejection, not just the first
for (const single of error.errors) {
console.error(single.message);
}
}With the default stopOnError: true the first rejection propagates but already-started mappers keep running in the background; stopOnError: false waits for all and throws one AggregateError.
Cancel a batch with AbortControllerabort-with-signal
import pMap from 'p-map';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
await pMap(items, process, {concurrency: 8, signal: controller.signal});
} finally {
clearTimeout(timeout);
}Aborting rejects the pMap promise with the abort reason; it does not magically stop in-flight mappers unless they also observe the signal, so pass it into fetch and friends too.
Drop failures from the result instead of throwingskip-failed-items
import pMap, {pMapSkip} from 'p-map';
const pages = await pMap(
urls,
async url => {
try {
return await fetchPage(url);
} catch {
return pMapSkip;
}
},
{concurrency: 5}
);
// pages contains only the successes, order preservedpMapSkip is a sentinel return value, not an option; the skipped slots are removed from the output array entirely rather than left as undefined holes.
Consume results as they are readystream-results
import {pMapIterable} from 'p-map';
for await (const post of pMapIterable(postIds, getPostMetadata, {concurrency: 8})) {
render(post);
}Still yields in input order, so one slow early item delays later finished ones; the win over pMap is starting to consume before the whole batch completes, with bounded memory.
Stop mappers racing ahead of a slow consumerbackpressure-control
import {pMapIterable} from 'p-map';
for await (const row of pMapIterable(ids, fetchRow, {
concurrency: 10,
backpressure: 20
})) {
await database.insert(row); // slow consumer
}backpressure caps how many finished-but-unconsumed results can pile up (default equals concurrency, cannot be lower); without it a fast mapper and slow consumer buffer the whole dataset.
Map over an async iterable sourceasync-iterable-input
import pMap from 'p-map';
async function * readQueue() {
while (await queue.hasMessages()) {
yield queue.next();
}
}
const outcomes = await pMap(readQueue(), handleMessage, {concurrency: 3});Input can be any sync or async iterable, and each yielded item is awaited before the mapper runs, so an iterable of promises also works.
Use the index argument in the mapperuse-item-index
import pMap from 'p-map';
const uploaded = await pMap(
files,
async (file, index) => {
console.log(`starting ${index + 1}/${files.length}`);
return upload(file);
},
{concurrency: 2}
);The mapper receives (element, index) only; there is no third 'array' argument like Array#map, so close over the input if you need it.
Combine with p-throttle for per-second limitsrate-limit-requests
import pThrottle from 'p-throttle';
import pMap from 'p-map';
const throttle = pThrottle({limit: 10, interval: 1000, strict: true});
const results = await pMap(inputs, throttle(callApi), {concurrency: 5});concurrency bounds promises in flight, not calls per second; an API with a requests-per-second quota needs the throttle wrapper too, exactly as the README's recipe shows.
Bounded-concurrency for side effects onlychunked-side-effects
import pMap from 'p-map';
await pMap(
staleKeys,
key => cache.delete(key),
{concurrency: 20, stopOnError: false}
);Nothing requires you to use the returned array; for pure side-effect fan-out, stopOnError: false plus a caught AggregateError gives you a full failure report in one pass.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | You want the lower-level primitive: one limit() wrapper you apply to arbitrary promise-returning calls, not a map over a list. |
| p-queue | npm | You need an ongoing queue with priorities, pause/resume, rate intervals, and timeouts rather than a single batch. |
| p-all | npm | You have an array of different functions to run with limited concurrency instead of one mapper over many inputs. |