p-each-series review
p-each-series 3.0.0 runs one iterator call at a time over a synchronous iterable. It awaits each input value, calls the iterator with that resolved value and a zero-based index, then waits for the iterator before advancing. The iterator's return value is discarded except for the exported `pEachSeries.stop` symbol, which ends the loop. The final result is the original iterable, not collected callback results. Version 3 is the April 2021 release that moved the package to pure ESM and raised the stated floor to Node 12. No feature release has followed, and the repository's last push was in 2022.
p-each-series 3.0.0 installed in 0.5 seconds, used 1 MB, bundled to 0.2 KB gzipped, and produced 0 audit findings in our sandbox. Install it only when the stop sentinel and side-effect-only contract earn their keep; a plain `for...of` loop expresses most serial workflows with fewer surprises.
We installed it
| Install | ✓ · 0.5s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 0.2 KB | gzipped (0.3 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does p-each-series install cleanly?
Yes. In a fresh container with an empty cache, npm install p-each-series finished in 0.5s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does p-each-series add to a browser bundle?
0.2 KB gzipped (0.3 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does p-each-series work with both ESM and CommonJS?
Yes. Both import 'p-each-series' and require('p-each-series') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does p-each-series include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
p-each-series or p-map: which should you use?
p-map: Use it when results matter or several items should run concurrently under an explicit limit. p-each-series 3.0.0 installed in 0.5 seconds, used 1 MB, bundled to 0.2 KB gzipped, and produced 0 audit findings in our sandbox.
When should you not use p-each-series?
You build an array of already-started promises and expect their underlying work to run serially. Promise executors start when created, before p-each-series awaits them.
Use it if
- Side effects such as ordered writes or rate-limited calls must finish strictly one iterator invocation at a time.
- Input may mix ordinary values and promises, and callback results are irrelevant to the caller.
- A callback needs both the resolved value and its zero-based position in the iterable.
- Iteration should stop cleanly on a sentinel while the function still returns the original input object.
- You build an array of already-started promises and expect their underlying work to run serially. Promise executors start when created, before p-each-series awaits them.
- The callback transforms values and the caller needs those results. This package ignores callback return values; use p-map-series or a plain loop that pushes results.
- More than one item may run at once. The implementation has no concurrency setting, so `p-map` is the better fit for bounded parallel work.
- Your source is an `AsyncIterable`. The implementation uses synchronous `for...of`, not `for await...of`, and its declared input type is `Iterable`.
- You need active maintenance or recent runtime targeting. Version 3.0.0 dates to 2021, the repository was last pushed in 2022, and Node 12 in its engine range is long out of support.
Setup reality
We installed p-each-series 3.0.0 in a fresh Node 22 Bookworm sandbox. npm completed in 0.5 seconds and left 1 package using 1 MB on disk. The package is 24 KB unpacked with 0 direct dependencies and 0 peer dependencies. npm audit found 0 known vulnerabilities. It declares Node 12 or newer and bundles TypeScript declarations.
Version 3 is an ESM package with an exports map. ESM import worked, and require() also worked in our Node 22 check, but older CommonJS runtimes cannot assume modern Node's ESM require behavior. There is no native build, config file, credential, or runtime service. The browser bundle measured 0.3 KB minified and 0.2 KB gzipped, so installation weight is not the decision point.
Serial execution starts inside the iterator. If fetchA() and fetchB() are called while constructing [fetchA(), fetchB()], both operations begin before p-each-series sees the array. Pass plain values and start the async operation in the iterator, or use a lazy synchronous generator that creates each promise on demand. The function rejects immediately when an input promise or iterator call rejects; it has no retry, timeout, cancellation, or rollback policy.
Returning pEachSeries.stop ends the loop after the current item. The fulfilled value remains the same original iterable, including items never visited. Callback values are otherwise ignored. A Set or generator is accepted at runtime, although the bundled declaration says the promise resolves to an array, which is narrower than the JavaScript implementation. For new code, a for...of loop is often clearer and avoids that type mismatch.
Patterns
Wait for one side effect before starting the next run-side-effects-serially
import pEachSeries from 'p-each-series';
await pEachSeries(records, async (record) => {
await saveRecord(record);
});Pass unstarted values in `records`. The iterator call is where each asynchronous side effect should begin.
Read the zero-based iterator index use-index
await pEachSeries(files, async (file, index) => {
await upload(file, { position: index });
});The index increments once per visited element and stops increasing after a rejection or stop sentinel.
Stop after a matching value stop-early
await pEachSeries(rows, async (row) => {
await inspect(row);
if (row.status === 'terminal') return pEachSeries.stop;
});The current iterator finishes before the sentinel is checked. Later elements are not visited.
Keep the original iterable as the result understand-return-value
const input = ['a', 'b', 'c'];
const result = await pEachSeries(input, async value => write(value));
console.log(result === input); // trueIterator results are discarded. The function fulfills with the exact input object, including unvisited items after an early stop.
Start promises inside the iterator avoid-eager-promises
const urls = ['/a', '/b', '/c'];
await pEachSeries(urls, url => fetch(url));Writing `[fetch('/a'), fetch('/b')]` starts both requests immediately, so wrapping that array does not serialize network activity.
Use a synchronous generator for lazy inputs produce-promises-lazily
function* requests(urls) {
for (const url of urls) yield fetch(url);
}
await pEachSeries(requests(urls), response => consume(response));A synchronous generator creates each fetch when the loop asks for its next value. Async generators are not accepted by version 3.0.0.
Attach context to the first failure handle-failure
try {
await pEachSeries(records, async (record, index) => {
try {
await saveRecord(record);
} catch (cause) {
throw new Error(`record ${index} failed`, { cause });
}
});
} catch (error) {
report(error);
}The first rejected input or iterator call stops the loop. The package does not roll back side effects from earlier items.
Retry one item before advancing retry-each-item
await pEachSeries(records, async record => {
for (let attempt = 1; ; attempt += 1) {
try {
await saveRecord(record);
return;
} catch (error) {
if (attempt === 3) throw error;
}
}
});Retries stay inside the iterator, so item 2 cannot begin while item 1 is retrying. Add delay and retry classification for a real remote service.
Bound the time spent on one item add-timeout
await pEachSeries(urls, async url => {
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
await consume(response);
});p-each-series supplies no timeout or abort signal. Cancellation must come from the operation being awaited.
Collect results explicitly when order matters collect-results
const output = [];
await pEachSeries(inputs, async input => {
output.push(await transform(input));
});The callback's return value is ignored. For routine mapping, `p-map` with concurrency 1 or a plain loop states this intent more directly.
Process a Set in insertion order iterate-set
const ids = new Set(['a', 'b', 'c']);
const result = await pEachSeries(ids, id => remove(id));
console.log(result === ids); // trueRuntime returns the original Set. The bundled declaration's array return type does not describe this case accurately.
Use the native equivalent for ordinary work replace-with-native-loop
for (const [index, value] of values.entries()) {
await run(value, index);
}A native loop already serializes iterator calls and makes `break`, collected results, and error handling visible without a dependency.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-map | npm | Use it when results matter or several items should run concurrently under an explicit limit. |
| p-series | npm | Use it when the input is a sequence of promise-returning functions rather than values passed to one iterator. |
| async | npm | Use it when the workflow also needs queues, retries, waterfalls, or callback-compatible control flow. |
| bluebird | npm | Use its collection helpers only in an existing Bluebird codebase; adding a full promise library for one serial loop is hard to justify. |
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.

