Parallel vs Sequential Async Tasks, Limiting Concurrency
When you have many async tasks, such as fetching 100 users or uploading 50 files, you must choose how to run them: one after another, all at once, or a few at a time. The right choice can make your code 10x faster, or stop it from crashing a server.
Definition
- Sequential: run tasks one after another. Each task starts only after the previous one finishes.
- Parallel: start all tasks at once, and wait for all of them to finish.
- Limited concurrency: run at most N tasks at a time. When one finishes, the next one starts.
Analogy: think of a supermarket with 100 customers.
- Sequential: one checkout counter. Safe, but very slow.
- Parallel: open 100 counters at once. Fast, but the store doesn't have 100 cashiers.
- Limited concurrency: open 5 counters. When a customer leaves a counter, the next one in line steps up.
| Sequential | Parallel | Limited concurrency | |
|---|---|---|---|
| Tasks running at once | 1 | All | N |
| Speed | Slowest | Fastest | Fast |
| Load on server / system | Lowest | Highest | Controlled |
| Use when | Each task needs the previous result | Few independent tasks | Many independent tasks |
Note: JavaScript runs on one thread, so "parallel" here really means the tasks are waiting at the same time. The actual I/O (network, disk) happens outside the main thread, which is why running them together is faster.
The Example Task
Each call takes 1 second:
function fetchUser(id) {return new Promise((resolve) => {setTimeout(() => resolve({ id, name: "User " + id }), 1000);});}const ids = [1, 2, 3, 4, 5, 6];
1. Sequential
async function runSequential() {const users = [];for (const id of ids) {const user = await fetchUser(id); // waits before starting the nextusers.push(user);}return users;}// Takes about 6 seconds
Use it when:
- Each step depends on the previous one (get user, then their orders).
- Order of side effects matters (database migrations, writing lines to a file).
- The other side allows only one request at a time.
2. Parallel
async function runParallel() {const users = await Promise.all(ids.map((id) => fetchUser(id)));return users;}// Takes about 1 second
ids.map(...) starts all 6 calls immediately, and Promise.all waits for every one. The results come back in the same order as ids, even if they finish in a different order.
A Promise Starts When It Is Created
A promise starts running when you call the function, not when you await it:
// Sequential: about 2 secondsconst a = await fetchUser(1);const b = await fetchUser(2);// Parallel: about 1 second (both started before any await)const pa = fetchUser(1);const pb = fetchUser(2);const a2 = await pa;const b2 = await pb;
The second style works, but prefer Promise.all. If pb rejects while you are still awaiting pa, its error is unhandled, which can crash Node.js.
The Problem With Unlimited Parallel
Promise.all on 10,000 items starts 10,000 tasks at once:
| Problem | What happens |
|---|---|
| Rate limits | The API returns 429 Too Many Requests or blocks you |
| Too many open files | Node.js fails with EMFILE errors |
| Database overload | The connection pool runs out, and queries time out |
| Memory | Thousands of pending requests and responses fill up memory |
| Thread pool | fs and crypto tasks queue behind libuv's 4 threads anyway |
The fix is to limit concurrency.
3. Limiting Concurrency
Option A: Batching
Split the items into groups of N, and run each group with Promise.all:
async function runInBatches(items, batchSize, fn) {const results = [];for (let i = 0; i < items.length; i += batchSize) {const batch = items.slice(i, i + batchSize);const batchResults = await Promise.all(batch.map(fn));results.push(...batchResults);}return results;}const users = await runInBatches(ids, 2, fetchUser);// 3 batches of 2, about 3 seconds
Simple, but each batch waits for its slowest task. While one slow task runs, the other slots sit idle.
Option B: A Pool of Workers (Better)
Start N workers. Each worker picks the next item as soon as it finishes the current one, so no slot sits idle.
async function mapWithLimit(items, limit, fn) {const results = new Array(items.length);let nextIndex = 0;async function worker() {while (nextIndex < items.length) {const i = nextIndex++; // claim the next itemresults[i] = await fn(items[i]);}}const workers = Array.from({ length: Math.min(limit, items.length) }, () =>worker(),);await Promise.all(workers);return results;}const users = await mapWithLimit(ids, 2, fetchUser);// At most 2 requests at a time, about 3 seconds
nextIndex++ is safe here because JavaScript is single-threaded: two workers can never run that line at the same moment.
Batching vs Pool
With 4 tasks that take 3s, 1s, 1s, 1s and a limit of 2:
| Approach | What happens | Total time |
|---|---|---|
| Batching | Batch 1 waits for the 3s task. Then batch 2 runs | 4 seconds |
| Pool | Worker A runs the 3s task. Worker B runs the three 1s tasks meanwhile | 3 seconds |
Option C: The p-limit Library
In real projects, a small, well-tested library like p-limit is a common choice:
import pLimit from "p-limit";const limit = pLimit(3); // at most 3 at a timeconst users = await Promise.all(ids.map((id) => limit(() => fetchUser(id))));
Note: recent versions of
p-limitare ES Modules only, so useimport, notrequire.
Choosing the Right Approach
| Situation | Use |
|---|---|
| Step 2 needs the result of step 1 | Sequential |
| 2 to 10 independent calls (user, orders, settings) | Parallel with Promise.all |
| Hundreds or thousands of items | Limited concurrency (pool or p-limit) |
| You need every result, even if some fail | Promise.allSettled, or catch errors inside each task |
| An API allows N requests at a time | Limited concurrency with a limit of N |
Choosing the limit: start with what the other side allows (API rate limit, database pool size), then measure. For CPU-heavy work, more than the number of CPU cores won't help.
Summary
- Sequential runs tasks one by one. Use it when tasks depend on each other.
- Parallel starts everything at once with
Promise.all. Fastest, but risky with many tasks. - A promise starts when it is created, not when it is awaited.
- Unlimited parallel can cause rate limits,
EMFILEerrors, database overload, and memory problems. - Limited concurrency runs at most N tasks at a time.
- Batching is simple but waits for the slowest task in each batch. A worker pool keeps every slot busy.
- In real projects,
p-limitis a common way to limit concurrency.
Interview Answer: How Do You Run Many Async Tasks Efficiently?
It depends on whether the tasks depend on each other. If each task needs the previous result, I run them sequentially with await inside a for...of loop. If they are independent and there are only a few, I run them in parallel with Promise.all, so the total time is roughly the time of the slowest task instead of the sum of all of them.
For many tasks, unlimited parallelism is dangerous: it can hit API rate limits, cause EMFILE errors, or exhaust the database connection pool. So I limit concurrency. Batching with Promise.all works, but each batch waits for its slowest task. A better approach is a worker pool: N workers each pick the next item as soon as they finish, so no slot sits idle. In production, I usually use a library like p-limit, and pick the limit based on what the API or database can handle.