It's the most natural-looking line in modern JavaScript:
await Promise.all(ids.map((id) => processOrder(id)));
Fast, concise, parallel. In development with 20 IDs it finishes in a blink. Then a backfill job runs it against 200,000 IDs, and:
- The database CPU pins at 100%, or
too many clients alreadyfloods the logs - Other endpoints on the same service start timing out
- The third-party API starts returning
429 Too Many Requests - The process runs out of memory, or dies with
EMFILE: too many open files
Promise.all didn't do anything wrong. It did exactly what you asked. The problem is that you asked for unlimited concurrency, and you may not have realized it.
This post explains what Promise.all actually does, the specific ways unbounded fan-out hurts a Postgres-backed Node.js service, and several ways to throttle concurrency, from a five-line loop to a proper queue.
The misconception: Promise.all doesn't run things in parallel
Look at what happens in this line, step by step:
const promises = ids.map((id) => processOrder(id)); // (1) every task STARTS here
const results = await Promise.all(promises); // (2) this only WAITS
An async function begins executing the moment you call it. By the end of step 1, processOrder has been invoked N times, and N operations are already in flight. Promise.all at step 2 does no scheduling or throttling at all. It just waits for the promises that already exist.
So "use Promise.all to run things concurrently" is true, but incomplete. It has no concurrency limit. If the array has 200,000 items, you start 200,000 operations at once.
Two more properties that matter for failure handling:
-
Fail-fast, but no cancellation. If one promise rejects,
Promise.allrejects immediately, but the other tasks keep running. Your code has moved on (probably into an error handler) while hundreds of queries are still executing in the background. - All results are held in memory until everything finishes.
How unbounded concurrency hurts
1. Connection pool exhaustion and queue pile-up
With pg, the pool has a fixed max (default 10). Extra pool.query() calls don't fail, they wait in a queue:
const pool = new Pool({ max: 10, connectionTimeoutMillis: 2_000 });
// 5,000 queries start simultaneously: 10 run, 4,990 wait in the pool's queue
await Promise.all(ids.map((id) => pool.query('SELECT * FROM orders WHERE id = $1', [id])));
Consequences:
- Everyone queued behind this job waits too, including unrelated requests (login, health checks, your API traffic). A single background job can starve the whole service.
- Waiters beyond
connectionTimeoutMillisfail withtimeout exceeded when trying to connect, so a job that "should be fine" fails partway. - Thousands of pending promises, closures, and queued requests consume memory and add GC pressure.
(We covered this failure family in depth in the post on connection pool starvation.)
2. Overloading the database itself
The pool protects Postgres from connection floods, but only if every task shares it. Two common ways the database still gets crushed:
-
Per-task connections. Code that does
new Client()per task, or an ORM/serverless setup creating a pool per invocation, opens one real connection per concurrent task. Each Postgres connection is a separate backend process. Hundreds of them exhaustmax_connectionsand make everything slower, since too many concurrent active queries mean CPU and I/O contention, not speed. -
Heavy queries in lockstep. Even through a pool, if each query is expensive and your pool is large, saturating the pool means the database runs
maxheavy queries simultaneously. Concurrency above what the hardware can usefully execute adds queueing and lock contention without adding throughput.
Bulk writes add their own problems: lock contention on hot rows, replication lag on replicas, and WAL volume spikes.
3. Hitting downstream rate limits
Calling an external API for each item with Promise.all is a reliable way to earn 429s, temporary bans, or throttled accounts. The retries then pile on more load.
4. Memory and file descriptors
Each in-flight task holds buffers, response bodies, and promise state. Reading 10,000 files concurrently can hit EMFILE. Fetching 10,000 large responses concurrently can exhaust memory long before the work completes.
5. Retry storms and thundering herds
A brief database hiccup makes thousands of concurrent tasks fail at once, and if each retries on its own, they all retry at once. Bounded concurrency naturally limits how much work is exposed to a failure and how much re-attempts at once.
Reproduce it on purpose
Seeing the failure beats believing it:
// repro.ts
import { Pool } from 'pg';
const pool = new Pool({ max: 10, connectionTimeoutMillis: 2_000 });
const results = await Promise.allSettled(
Array.from({ length: 5_000 }, () => pool.query('SELECT pg_sleep(0.05)')),
);
const failed = results.filter((r) => r.status === 'rejected');
console.log(`failed: ${failed.length} / ${results.length}`);
console.log((failed[0] as PromiseRejectedResult | undefined)?.reason?.message);
// Typically: many failures with "timeout exceeded when trying to connect"
Each query takes ~50 ms and the pool runs 10 at a time, so the last queries in the queue wait far longer than the 2-second connection timeout. Many fail even though every individual query is trivial. (Exact numbers depend on your machine.)
Fix 1: Process in chunks (simplest)
// ✅ At most `size` in flight at a time
async function inChunks<T, R>(items: T[], size: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = [];
for (let i = 0; i < items.length; i += size) {
const chunk = items.slice(i, i + size);
out.push(...(await Promise.all(chunk.map(fn))));
}
return out;
}
const results = await inChunks(ids, 10, (id) => processOrder(id));
It's dead simple, but there's an efficiency flaw: each chunk is only as fast as its slowest task. If one task in a chunk takes 5 seconds and the other nine take 50 ms, nine "slots" sit idle for ~5 seconds. With uneven task times this wastes a lot of capacity. The next options keep slots busy continuously.
Fix 2: p-limit (sliding-window concurrency)
p-limit gives you a limiter: wrap each task, and at most N run at once. As soon as one finishes, the next starts.
import pLimit from 'p-limit';
const limit = pLimit(5);
const results = await Promise.all(
ids.map((id) => limit(() => processOrder(id))), // note: the function is deferred
);
The crucial detail is limit(() => processOrder(id)). You must pass a function so the task doesn't start until the limiter allows it. Writing limit(processOrder(id)) calls processOrder immediately and defeats the purpose.
Caveats:
- Recent versions of
p-limit(andp-map) are ESM-only. In a CommonJS project, use dynamicimport()or pin an older version. - The
ids.map(...)above still creates N closures and promises up front, which is fine for thousands of items, but for millions you want an iterator-based approach (below).
Fix 3: p-map (map with concurrency built in)
p-map packages the pattern as a single call and has useful error options:
import pMap from 'p-map';
const results = await pMap(ids, (id) => processOrder(id), {
concurrency: 5,
stopOnError: true, // default: reject on first error and stop starting new tasks
});
Set stopOnError: false to run everything and get an AggregateError at the end. Because it accepts iterables, you can feed it a generator or lazily-produced sequence rather than materializing a giant array.
Fix 4: A tiny worker pool with no dependencies
Dependencies are optional. The core idea fits in a few lines: start limit workers, and each one repeatedly pulls the next item until none are left.
export async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
let failed = false;
async function worker() {
while (!failed) {
const i = next++; // safe: JS runs this synchronously, no race
if (i >= items.length) return;
try {
results[i] = await fn(items[i], i);
} catch (err) {
failed = true; // tell sibling workers to stop pulling new items
throw err;
}
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const results = await mapLimit(ids, 5, (id) => processOrder(id));
Why next++ is safe without locks: Node runs your JavaScript on a single thread, and there's no await between reading and incrementing next, so two workers can't claim the same index.
The failed flag matters. Without it, when one task throws, Promise.all rejects but the other workers keep pulling new items, continuing to do work nobody's waiting for, which is the "no cancellation" problem from earlier.
If you'd rather collect every outcome instead of failing fast, catch inside the worker and store { status, value | reason } per item, mirroring Promise.allSettled.
Fix 5: Don't load everything into memory first
Throttling the tasks doesn't help if you first load 5 million rows into an array. For big jobs, make the producer lazy so there's a bounded amount of work in memory at any time.
import { once } from 'node:events';
// Page through the source with keyset pagination and process a bounded window
async function backfill() {
let lastId = 0;
for (;;) {
const { rows } = await pool.query(
'SELECT id FROM orders WHERE id > $1 ORDER BY id LIMIT 1000',
[lastId],
);
if (rows.length === 0) break;
await mapLimit(rows, 8, (row) => processOrder(row.id));
lastId = rows[rows.length - 1].id;
}
}
Memory stays proportional to the page size (1,000), not the table size. Keyset pagination (WHERE id > $1 ORDER BY id LIMIT n) also stays fast on deep pages, unlike OFFSET. For CSV or file inputs, the same idea applies with streams: read a line, hand it to a bounded processor, and rely on backpressure instead of buffering the whole file.
Fix 6: Reduce the number of tasks (batch in SQL)
The cheapest concurrent query is the one you don't run. If each task is "update one row", you may not need N tasks at all:
// ❌ 10,000 tasks, even throttled: 10,000 round trips
await mapLimit(updates, 10, (u) => pool.query('UPDATE orders SET status = $1 WHERE id = $2', [u.status, u.id]));
// ✅ 1 round trip per 1,000 rows
for (const batch of chunk(updates, 1000)) {
await pool.query(
`UPDATE orders o
SET status = v.status
FROM unnest($1::bigint[], $2::text[]) AS v(id, status)
WHERE o.id = v.id`,
[batch.map((u) => u.id), batch.map((u) => u.status)],
);
}
Passing arrays and expanding them with unnest lets one statement handle a whole batch. The same pattern works for inserts (INSERT ... SELECT ... FROM unnest(...)), and for very large loads, COPY is faster still. Combine the two ideas: batch to reduce round trips, then throttle the batches.
Fix 7: For big or durable work, use a queue
If the job is huge, must survive restarts, needs retries, or shouldn't block your web process, concurrency control belongs in a job queue rather than in an in-process loop. For example with BullMQ:
import { Queue, Worker } from 'bullmq';
const queue = new Queue('backfill', { connection });
await queue.addBulk(ids.map((id) => ({ name: 'process-order', data: { id } })));
new Worker('backfill', async (job) => processOrder(job.data.id), {
connection,
concurrency: 5, // per-worker cap
limiter: { max: 100, duration: 1_000 }, // optional: at most 100 jobs per second
});
You get durable state, retries with backoff, a rate limiter, and the ability to scale by adding workers. Remember that with several workers, the total concurrency is workers × concurrency, so size the sum against your database, not each worker in isolation.
Concurrency limit vs. rate limit
These are different controls, and APIs often need the second:
- Concurrency limit: at most N operations in flight at once. Protects pools, memory, and database load.
-
Rate limit: at most N operations per time window (for example, 50 requests per second). Protects against
429s.
A concurrency cap of 10 with 20 ms calls is still ~500 requests/second, which may break a 100/s API limit. Libraries such as bottleneck support both concurrency and rate limits in one limiter. Pick based on what the downstream system actually constrains.
How to choose the number
There's no universal value, but there's a sensible way to pick one:
- Never exceed what the downstream can serve. For database work, stay below your pool size, and leave headroom for normal traffic, since the pool is shared. If the pool is 10 and the API needs to stay responsive, a background job might get 3 to 5.
-
Account for every instance.
instances × concurrencyis the real load. Ten pods each running a 10-wide job is 100 concurrent operations. - Measure. Run the job at concurrency 1, 2, 4, 8, 16 and watch throughput and p95 latency. Throughput rises, flattens, then often drops as contention takes over. Choose a value at or just before the knee.
- Little's Law helps with intuition: concurrency ≈ throughput × latency. If each query takes 20 ms and you want 250 queries/second, you need about 5 in flight. More than that mostly adds queueing.
for (const concurrency of [1, 2, 4, 8, 16, 32]) {
const start = performance.now();
await mapLimit(sample, concurrency, (id) => processOrder(id));
const secs = (performance.now() - start) / 1000;
console.log(`concurrency=${concurrency} throughput=${(sample.length / secs).toFixed(0)}/s`);
}
Error handling that doesn't leave a mess
-
Prefer
Promise.allSettledwhen partial success is acceptable, so one failure doesn't hide the other results. -
Add per-task timeouts (
AbortSignal.timeout(ms)) so one hung task can't occupy a slot forever. - Retry transient errors with backoff and jitter, a bounded number of times, inside the limited task so retries also respect the concurrency cap.
- Make tasks idempotent. Throttled jobs still get interrupted, rerun, and retried, so processing an item twice should be harmless.
- Stop cleanly on shutdown. Have workers check a shutdown signal before taking the next item, so a deploy doesn't kill a job mid-task.
Choosing an approach
| Approach | Slots stay busy? | Handles huge inputs? | Dependency | Best for |
|---|---|---|---|---|
Promise.all on everything |
n/a (unbounded) | No | None | Small, bounded N only |
Chunked Promise.all
|
No (waits for slowest) | Yes | None | Simple scripts, uniform task times |
p-limit / p-map
|
Yes | Moderate (p-map with iterables: yes) |
Yes (ESM) | General-purpose throttling |
Hand-rolled mapLimit
|
Yes | Moderate | None | No-dependency code, custom error rules |
| Lazy paging + limiter | Yes | Yes | Optional | Backfills over big tables |
SQL batching (unnest) |
n/a | Yes | None | Many small writes |
| Job queue | Yes | Yes | Yes | Durable, retried, scalable work |
Checklist
- ✅ Ask of every
Promise.all(xs.map(...)): how big canxsget? If unbounded, throttle it - ✅ Keep a task's concurrency well below your pool size, leaving headroom for live traffic
- ✅ Pass a function to the limiter, not an already-started promise
- ✅ Use
allSettledor a fail-flag so errors don't leave orphaned work running - ✅ Page through big sources instead of loading them whole
- ✅ Batch small writes with
unnestorCOPY - ✅ Separate concurrency limits from rate limits
- ✅ Measure throughput at several concurrency levels, and size for all instances combined
Key takeaways
-
Promise.alldoesn't parallelize or throttle. Tasks start the moment you create the promises, andPromise.alljust waits. - Unbounded fan-out causes pool starvation, database overload, rate-limit errors, and memory blowups, often while every individual query looks fast.
-
Throttle with a sliding window:
p-limit,p-map, or a small worker pool. Chunking works but wastes slots on uneven tasks. - Reduce the work too. Batch in SQL, and stream or page large inputs rather than materializing them.
- Concurrency isn't rate limiting. Know which one your downstream needs.
- Pick the number by measurement, accounting for every instance and for the traffic sharing your pool.
- Use a real queue when work is large, long-running, or must survive restarts.
Have you been burned by an innocent-looking Promise.all? Tell the story in the comments.
Further reading: p-limit, p-map, bottleneck, and the node-postgres pooling docs.
Top comments (0)