Running tasks with a concurrency limit
Run many async tasks with at most limit in flight at once, starting the next task as soon as one finishes.
O(n) bookkeepingSpace O(n + limit)5 practice problemsStep-through pictureThe idea
Uploading 200 photos or prefetching 500 images at once would open hundreds of network requests. Browsers limit connections per server anyway, the server may reject the flood, and nothing finishes quickly. Running them one by one is safe but slow. The middle path is a concurrency limit: at most limit tasks run at the same time.
A neat way to build it is a pool of workers. Start limit workers. Each worker takes the next task index from a shared counter next, waits for that task to finish, and then takes another, until no tasks are left. Because each worker only takes a new task after finishing one, there are never more than limit tasks running.
To predict the timing, simulate it: each task has a duration. At time 0 the first limit tasks start. Whenever a task finishes, the worker that ran it starts the next task right then. The finish times follow from that rule, and a min-heap of finish times makes the simulation fast.
An everyday example
Checkout lanes at a shop
A shop has three checkout lanes and a single line of customers. The first three customers go straight to a lane. When any lane finishes, the next person in line walks to that lane. No lane ever serves two people at once, and nobody waits while a lane is free.
| In the example | In the code |
|---|---|
| The checkout lanes | The limit workers |
| The single line of customers | tasks, taken in order |
| "Next, please" | const index = next++ |
| Serving one customer | await tasks[index]() |
| The shop closes when the line is empty | await Promise.all(workers) |
From the slow way to running tasks with a concurrency limit
- Start with the two obvious ways.
Promise.all(tasks.map((task) => task()))starts everything at once: 200 uploads open 200 requests, the server may reject the flood, and over HTTP/1.1 the browser queues most of them anyway. Aforloop withawaitruns one task at a time: safe, but the total time is the sum of every duration. - The middle path is to run
limitat a time. The first idea is batches: startlimittasks, wait for all of them, then start the next batch. But each batch waits for its slowest task while the other slots sit empty. In the trace below, batches of 3 would take250 + 300 = 550ms. - The better rule is "as soon as a slot is free, fill it". Picture
limitcheckout lanes and one line of tasks: whenever a lane finishes, it calls the next task in line. So write a worker that loops: take the next task, wait for it, take another, until none are left. Startlimitworkers. - The workers share one counter,
next, the index of the first task nobody has taken. Each result goes intoresults[index], so the output keeps the input's order even though tasks finish in a different order. Waiting for every worker withPromise.all(workers)means every task is done. That pool of workers is running tasks with a concurrency limit.
How it works
- Make
resultswith one slot per task and a shared counternext = 0. - Define
worker: whilenextis less than the number of tasks, takeindex = next++, awaittasks[index](), and store the value inresults[index]. - Start
Math.min(limit, tasks.length)workers and keep their promises inworkers. - Wait for all workers with
Promise.all(workers). Each worker only stops when no tasks are left. - Return
results, which is in the original task order, not the finishing order.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
async function runWithLimit(tasks, limit) {const results = new Array(tasks.length);let next = 0;async function worker() {while (next < tasks.length) {const index = next++;results[index] = await tasks[index]();}}const workers = [];for (let w = 0; w < Math.min(limit, tasks.length); w++) {workers.push(worker());}await Promise.all(workers);return results;}
How fast it is
| Time | O(n) bookkeeping |
|---|---|
| Extra memory | O(n + limit) |
Each task is handed out once and its result stored once. The wall-clock time depends on the durations: roughly the total work divided by limit, but never less than the longest task.
- Best case
O(n)The bookkeeping is always one hand-out and one stored result per task. The wall-clock time is best when tasks have equal durations and
nis a multiple oflimit: then it's exactly the total work divided bylimit. - Average case
O(n)Mixed durations, like the trace: the wall-clock time lands between
total / limitand that plus the longest task. - Worst case
O(n)Still
O(n)bookkeeping. The wall-clock worst case is a long task handed out last:[100, 100, 100, 100, 400]withlimit = 2ends at 600, while putting the 400 first would end at 400.
Common mistakes
- Calling all the task functions first, as in
tasks.map((t) => t()), and then limiting. The requests have already started; pass functions that start work when called. - Splitting tasks into fixed batches of
limitand awaiting each batch. One slow task holds up the whole batch while other slots sit idle. - Pushing results in finishing order with
results.push(value). The order then changes from run to run; store byindex. - Letting one failure stop everything without meaning to. Decide whether to fail fast or collect errors per task.
When to use it
Look for these clues in a problem:
- Many requests, uploads, or jobs must run, but only a few at a time.
- The question gives task durations and a limit, and asks for finish times or the total time.
- Results must stay in the original order even though tasks finish out of order.
- It mentions a pool, a queue of jobs, rate limits, or
Promise.allwith a limit.
Check yourself
Answer each one in your head first, then open it.
Why can't two workers both take task 3?
JavaScript runs one piece of code at a time, and a worker only pauses at await. Reading next and adding one happen together, so the second worker always sees the new value.
Why do fixed batches take 550 ms in the trace's example while the pool takes 400 ms?
A batch waits for its slowest task: 250 ms for the first batch and 300 ms for the second. The pool starts a new task the moment any slot is free, so short tasks fill the gaps.
With limit = 1, how long does the trace's example take?
950 ms, the sum of all durations, because one worker runs every task in turn.
What is the shortest possible total time for any limit?
At least the longest single task, and at least the total work divided by limit, whichever is larger. For the trace that's max(300, 950 / 3), about 317 ms; the pool got 400.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 5 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
At most limit tasks run at once, because there are only Math.min(limit, tasks.length) workers, and a worker takes a new task only after await gives back the result of its current one. No worker ever runs two tasks at the same time.
Each task runs exactly once, because of how JavaScript runs code: between two awaits, a worker runs without anyone else running. The check next < tasks.length, the read of next, and the ++ all happen in one such stretch, so two workers can never get the same index. A worker stops only when every index has been handed out, so no task is skipped.
The answer is complete and in order. Each result is stored at its own index, not pushed in finishing order. A worker's promise resolves only after its last task has finished, so when Promise.all(workers) resolves, every task is done and every slot of results is filled. And no slot sits idle while a task is waiting, because a worker takes the next task the moment its last one ends.
Going deeper
3 more ideas
Why next++ never gives a task to two workers
JavaScript runs one piece of code at a time. Between two awaits, a worker runs without being interrupted, so reading next and adding one happen together. Two workers can't both read the same value, so every index is taken exactly once.
Simulating it with a heap
To compute finish times for n tasks, keep a min-heap of the times when each worker becomes free, starting with limit zeros. For each task in order, take the earliest free time t, the task finishes at t + duration, and put that back. That is O(n log limit). With a small limit, scanning the workers' free times is simpler and just as fast.
Errors and cancellation
If a task throws, this version rejects the whole Promise.all while other workers keep going. Real code often catches each error and stores it in results, like Promise.allSettled. Passing an AbortSignal lets the caller stop the remaining tasks.
Work it by hand
Every step in a table, with why
Run 6 tasks with 3 workers. Short tasks free their worker early, so worker 2 runs three tasks while worker 0 runs one.
durations = [250, 100, 150, 300, 50, 100] (task `i` waits that many ms, then returns `ri`), limit = 3
| Step | Time | Event | next | Running (worker: task) | Why |
|---|---|---|---|---|---|
| 1 | 0 | worker 0 takes task 0 | 1 | w0: 0 | The first worker starts. index = next++ hands out task 0, which will end at 250. |
| 2 | 0 | worker 1 takes task 1 | 2 | w0: 0, w1: 1 | Task 1 will end at 100. |
| 3 | 0 | worker 2 takes task 2 | 3 | w0: 0, w1: 1, w2: 2 | Math.min(3, 6) is 3, so this is the last worker. All 3 slots are busy. |
| 4 | 100 | task 1 done, worker 1 takes task 3 | 4 | w0: 0, w1: 3, w2: 2 | results[1] is stored and worker 1 loops at once. Task 3 is the longest, 300 ms, ending at 400. |
| 5 | 150 | task 2 done, worker 2 takes task 4 | 5 | w0: 0, w1: 3, w2: 4 | Task 4 is short and will end at 200. |
| 6 | 200 | task 4 done, worker 2 takes task 5 | 6 | w0: 0, w1: 3, w2: 5 | next is now 6, equal to tasks.length: every task has been handed out. |
| 7 | 250 | task 0 done, worker 0 stops | 6 | w1: 3, w2: 5 | next < tasks.length is false, so worker 0's loop ends and its promise resolves. |
| 8 | 300 | task 5 done, worker 2 stops | 6 | w1: 3 | Worker 2 ran three tasks: 2, 4, and 5. |
| 9 | 400 | task 3 done, worker 1 stops | 6 | none | The last worker finishes, so Promise.all(workers) resolves. |
It returns [r0, r1, r2, r3, r4, r5] at 400 ms, in task order even though the finish order was 1, 2, 4, 0, 5, 3. One at a time would take 950 ms, and fixed batches of 3 would take 550 ms.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Running tasks with a concurrency limit | O(n) | O(n) | Many requests or jobs that must not all run at once, where you want every slot busy until the work runs out. |
Promise.all on everything | O(n) | O(n) | A small number of tasks, like 5 requests, where starting them all at once is fine and fastest. |
Fixed batches of limit | O(n) | O(n) | The API itself takes work in batches, or each batch must finish before the next can start. Otherwise slow tasks leave slots idle. |
One at a time with await in a loop | O(n) | O(n) | Each task depends on the one before, or the server allows only one request at a time. The total time is the sum of all durations. |
Extra memory: O(n + limit): the results array plus one waiting promise per worker.
How the steps grow
- Running tasks with a concurrency limit,
Promise.allon everything, Fixed batches oflimit, One at a time withawaitin a loopO(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Running tasks with a concurrency limit | 10 | 1,000 | 1 million |
Promise.all on everything | 10 | 1,000 | 1 million |
Fixed batches of limit | 10 | 1,000 | 1 million |
One at a time with await in a loop | 10 | 1,000 | 1 million |
Inputs that need care
6 tricky inputs
- No tasks:
tasks = [] - No workers start,
Promise.all([])resolves at once, and it returns[]. limitlarger than the number of tasksMath.min(limit, tasks.length)starts one worker per task, so they all run at once and each worker stops after one task.limit = 0or a negative limit- No worker starts, so no task ever runs, and it returns an array of empty slots. Guard with
Math.max(1, limit)or throw an error. - A fractional limit like
2.5 - The loop runs for
w = 0,1, and2, so 3 workers start. Round the limit down first if 2 was meant. - One task throws
- That worker's promise rejects, so
Promise.all(workers)rejects with the error right away. The other workers keep starting tasks in the background, and the failed worker takes no more. Catch errors per task if you need every result. - Passing promises instead of functions:
tasks = urls.map((url) => fetch(url)) - The requests already started when the array was built, so the limit does nothing, and
tasks[index]()throws aTypeErrorbecause a promise isn't a function. Pass() => fetch(url)instead.
Variations and follow-ups
Twists interviewers add
A limiter you can add to at any time
Libraries like p-limit return a function: limit(() => fetch(url)). Each call goes into a queue, and whenever fewer than limit tasks are running, the next one in the queue starts. It's the same rule, but tasks can arrive while others run.
Collecting errors
Wrap each task in try/catch and store { status: "fulfilled", value } or { status: "rejected", reason } at results[index], like Promise.allSettled. One failed upload then doesn't hide the results of the others.
Priorities
Take tasks from a priority queue instead of in index order. An image loader can then start the images that are on screen before the ones far below.
Predicting the finish time
To compute when everything ends without running anything, keep a min-heap of the times each worker becomes free, starting with limit zeros. For each task in order, take the earliest time t and put back t + duration. That is O(n log limit); on the trace it gives 400.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "at most N at a time", "limit concurrency", "a pool of workers", or "
Promise.allbut not all at once". If it says "at most N per second", that's a rate limit, which is a different tool. - Explain the worker loop and the shared
nextcounter, then say why there is no race: JavaScript doesn't switch between workers in the middle ofnext++. - Test by hand by drawing a timeline like the trace, with durations that make one worker run several short tasks. Check the finish time and that results are in task order.
- Expect follow-ups: "what if a task fails?" (fail fast, or collect each result like
Promise.allSettled), "how would you cancel?" (pass anAbortSignaland stop taking tasks once it's aborted), and "when does everything finish?" (simulate with a min-heap).
Where frontend code uses it
The same idea in real UI code
- Upload widgets send a few files at a time so progress stays smooth and the server isn't flooded.
- Libraries like
p-limitandp-queueimplement this pattern, and image prefetchers and crawlers use it to cap open requests. - Browsers have their own limits: over HTTP/1.1 they open about 6 connections per host, and they queue extra requests the same way.