Limit Concurrency
Prompt
Zomato needs to upload 50 product images at once in a restaurant images upload flow, but the browser/server can only handle 5 concurrent uploads to avoid crashing or going OOM.
Write a function limitConcurrency(tasks, limit) that executes these tasks and returns all results only when finished.
tasks: An array of functions, where each function returns a Promise.limit: The maximum number of tasks that can run at the same time.- The function should return a Promise that resolves to an array of results, in the same order as the input tasks.
- As soon as one task completes, the next pending task should start immediately (don't wait for the entire batch to finish before starting the next one).
- If a task rejects, capture the error in the results array (as
{ error }) and continue processing the remaining tasks.
Playground
You can't just split the array into chunks and process each chunk with Promise.all. That approach waits for the slowest task in each batch before starting the next batch, which wastes time. Instead, you want to start a new task the instant any running task finishes.
Keep a pointer (index) to the next task that hasn't started yet. Write a runNext function that picks up the next task, runs it, stores the result, and then calls itself recursively to pick up another. Start limit copies of runNext running in parallel.
Create a results array pre-filled to the same length as tasks. Each runNext call should capture its own index before incrementing the shared pointer, so the result lands in the right slot. Use Promise.all to wait for all workers to finish, then return the results.
Solution
Explanation
I will walk you through this question step by step. We will look at what an ideal solution looks like, where candidates usually go wrong, and how you should think about this during an interview.
Now before we jump into the code, let's understand what we are actually trying to solve. Think about Zomato's restaurant dashboard where an owner wants to upload 50 product images at once. If we fire all 50 upload requests at the same time, the browser will struggle because browsers have a limit on how many concurrent connections they can maintain per domain (typically 6 in HTTP/1.1). The server might also run out of memory trying to process all of them at once. So we need a way to say "run at most 5 at a time, and whenever one finishes, immediately pick up the next one."
You will see this pattern everywhere in real applications — rate-limiting API calls, processing upload queues, capping concurrent database queries in Node.js, downloading multiple files without running out of memory.
Now, let's start solving.
The first thing candidates try — Promise.all
Most candidates immediately think "I'll just use Promise.all."
const results = await Promise.all(tasks.map((t) => t()));This fires all 50 tasks at the same time. There is no concurrency limit. If each task is an image upload, we just sent 50 requests to the server at the same time, which is exactly what we are trying to avoid. So Promise.all on its own won't work here.
The chunking approach — why it is not good enough
The next thing candidates try is splitting the tasks into groups and processing each group with Promise.all:
for (let i = 0; i < tasks.length; i += limit) {
const chunk = tasks.slice(i, i + limit);
const chunkResults = await Promise.all(
chunk.map((t) => t())
);
results.push(...chunkResults);
}This does respect the concurrency limit, but it wastes time. Imagine you have a limit of 5 and one task finishes in 50ms while the other four take 500ms. That fast task is done and sitting idle for 450ms, waiting for the slowest task in the batch before the next batch can begin. With hundreds of tasks, all that idle time adds up.
Common Pitfalls
The chunking approach is the most common mistake I see. It works, but it wastes time because of the idle gaps between batches. Interviewers will specifically ask "what happens if one task in a batch finishes early?" to check if you understand this problem. If they see you reaching for chunking, they will push you toward a better approach. It is better to get there on your own.
The right approach — worker pool
What we actually want is a worker pool. Think of it like checkout lanes at a grocery store. You have 5 lanes open (your concurrency limit). Each lane handles one customer at a time. The moment a customer leaves, the next person in line steps up. No lane sits idle while there are people waiting.
Let's build this step by step.
We will start by setting up our function with two things — a results array to hold all results in input order, and a nextIndex pointer to track which task should run next.
async function limitConcurrency(tasks, limit) {
const results = new Array(tasks.length);
let nextIndex = 0;
}We create the results array with the same length as tasks upfront. This is important — it makes sure each result ends up in the correct position no matter which task finishes first. We will talk more about why this matters later.
Now, we will write a runNext function. This is the core of the whole solution. Each worker runs this same function — it picks up the next available task, runs it, stores the result, and loops back for more until nothing is left.
async function runNext() {
while (nextIndex < tasks.length) {
const index = nextIndex++;
try {
results[index] = await tasks[index]();
} catch (error) {
results[index] = { error };
}
}
}Pay attention to the line const index = nextIndex++. This captures the current index before incrementing the shared pointer. This is how each worker knows which slot in the results array to write to.
No race condition here
You might wonder — if multiple workers are running at the same time, won't two of them grab the same index? The answer is no. JavaScript is single-threaded. Even though multiple workers are running "in parallel" (they are really interleaved via the event loop), the nextIndex++ happens synchronously before any await. The await is where the worker pauses, and by that point it has already taken its index. So two workers will never grab the same task.
Now we need to actually start the workers and wait for all of them to finish:
const workers = Array.from(
{ length: Math.min(limit, tasks.length) },
() => runNext()
);
await Promise.all(workers);
return results;We use Math.min(limit, tasks.length) because there is no point creating 10 workers for 3 tasks. Each runNext() call returns a Promise that resolves when that worker has no more tasks to pick up. Promise.all(workers) waits for every worker to finish, and then we return the results.
The complete solution
Here is everything put together:
async function limitConcurrency(tasks, limit) {
const results = new Array(tasks.length);
let nextIndex = 0;
async function runNext() {
while (nextIndex < tasks.length) {
const index = nextIndex++;
try {
results[index] = await tasks[index]();
} catch (error) {
results[index] = { error };
}
}
}
const workers = Array.from(
{ length: Math.min(limit, tasks.length) },
() => runNext()
);
await Promise.all(workers);
return results;
}Now I would like to discuss two things where candidates usually go wrong.
Order preservation
A lot of candidates push results onto an array as tasks finish:
results.push(await tasks[index]());The problem here is if task 3 finishes before task 0, the results end up in the wrong order. By using a pre-allocated array and writing to results[index], each result always lands in the same position as the input task, no matter which one finishes first. This is a small detail but interviewers notice it right away.
Error handling
If a task rejects and you don't catch it, the error stops the worker. That means one failing image upload stops all remaining uploads from being processed. Instead, we wrap each task in try/catch and store the error as { error } in the results array. The worker keeps going and picks up the next task.
Common Pitfalls
A lot of candidates forget about error handling. Without the try/catch, one failed upload would stop the remaining 45 images from being processed. In a real application, this would be a terrible experience for the restaurant owner. Always ask yourself: "What should happen when one task fails? Should everything stop, or should the rest continue?" Almost always, the answer is to continue.
Common mistakes I see
I would like to highlight two patterns that come up again and again:
- Using a
forloop withawait— This runs tasks one at a time, which removes the whole point of concurrency. If each upload takes 200ms, 50 uploads take 10 seconds sequentially vs 2 seconds with a pool of 5 workers.
// This is sequential, NOT concurrent
for (const task of tasks) {
const result = await task();
}- Overcomplicating with event emitters or callbacks — Some candidates try to build complex systems with event listeners or nested callbacks. The worker pool with a
whileloop is simpler and much easier to reason about. Keep it simple.
Real-world usage
This exact pattern is used by libraries like p-limit and async.parallelLimit. Upload tools like Dropzone and Uppy use it to limit concurrent file uploads. API clients use it to respect rate limits. Node.js servers use it to cap concurrent database queries. You will run into this pattern in production all the time.