On this page

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.
SequentialParallelLimited concurrency
Tasks running at once1AllN
SpeedSlowestFastestFast
Load on server / systemLowestHighestControlled
Use whenEach task needs the previous resultFew independent tasksMany 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 next
users.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 seconds
const 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:

ProblemWhat happens
Rate limitsThe API returns 429 Too Many Requests or blocks you
Too many open filesNode.js fails with EMFILE errors
Database overloadThe connection pool runs out, and queries time out
MemoryThousands of pending requests and responses fill up memory
Thread poolfs 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 item
results[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:

ApproachWhat happensTotal time
BatchingBatch 1 waits for the 3s task. Then batch 2 runs4 seconds
PoolWorker A runs the 3s task. Worker B runs the three 1s tasks meanwhile3 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 time
const users = await Promise.all(ids.map((id) => limit(() => fetchUser(id))));

Note: recent versions of p-limit are ES Modules only, so use import, not require.

Choosing the Right Approach

SituationUse
Step 2 needs the result of step 1Sequential
2 to 10 independent calls (user, orders, settings)Parallel with Promise.all
Hundreds or thousands of itemsLimited concurrency (pool or p-limit)
You need every result, even if some failPromise.allSettled, or catch errors inside each task
An API allows N requests at a timeLimited 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, EMFILE errors, 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-limit is 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.

Next TopicMiddleware Flow, next(), and Error-Handling Middleware