Write mapWithLimit(items, limit, fn): run an async function over a list, at most limit calls at a time.
Before you start
What's a concurrency limit?
Concurrency is how many operations are in flight at once: started, but not finished yet. When you have a list of async jobs, such as 200 files to upload or 50 pages to fetch, a concurrency limit of N means at most N of them run at the same time. The rest wait, and as soon as any running job finishes, the next waiting one starts in its place.
Picture a supermarket with three checkouts. However long the queue, at most three customers are served at once, and the moment any checkout is free, the next person in the queue goes to it. Nobody waits for all three to be free.
Why not start everything at once? Hundreds of requests at the same moment can overload a server or go over the number of requests it allows, and every response sits in memory at the same time. Browsers also open only a few HTTP/1.1 connections to the same host, usually six, and queue the requests beyond that. A limit keeps the load steady and under your control.
| How you run the jobs | In flight at once | What you get |
|---|---|---|
| In series: each one starts when the one before it finishes | 1 | The gentlest, and the slowest: the total time is the sum of all the jobs. It's a concurrency limit of 1. |
All at once: Promise.all(urls.map((url) => fetch(url))) | All of them | The fastest when nothing pushes back, but 500 URLs means 500 requests started at the same moment. |
| With a concurrency limit of N | At most N | Much faster than in series, while never more than N run at the same moment. |
The next job starts as soon as a slot frees up, not when a whole batch of N is done. Here are four jobs with a limit of 2, where A takes 300 ms and B, C, and D take 100 ms each:
| Time | In fixed batches of 2 | Starting whenever a slot frees up |
|---|---|---|
| 0 ms | A and B start. | A and B start. |
| 100 ms | B is done, but C waits for the rest of the batch. | B is done, so C starts. |
| 200 ms | Still waiting for A. | C is done, so D starts. |
| 300 ms | A is done, so C and D start. | A and D are done: all four have finished. |
| 400 ms | C and D are done: all four have finished. | Nothing left to do. |
In batches, B's slot sat empty from 100 to 300 ms while A finished, so the work took 400 ms instead of 300. You meet concurrency limits when uploading many files, prefetching images, or calling an API once for every item in a list; npm packages such as p-limit and p-map provide them.
Call fn(item, index), which returns a promise, for every item in items, with no more than limit calls running at once. Start a new call as soon as one finishes, so limit calls are running while work remains.
Resolve with the results in input order, not in the order the calls finish.
If a call rejects, reject with that error and don't start any more calls. A limit below 1 rejects with a RangeError.
async function mapWithLimit(items, limit, fn) {
// your code
}
// const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// const results = await mapWithLimit(["a", "b", "c"], 2, async (name) => {
// console.log("start", name);
// await wait(name === "a" ? 300 : 100);
// console.log("end", name);
// return name.toUpperCase();
// });
// logs start a, start b: two calls, the limit, so c waits
// then end b, start c: b is done, so c takes its place
// then end c, end a
// results; // ["A", "B", "C"]: input order
Solve it in the editor, with tests
The answer
Write it yourself first. Then compare it with a reference solution and the checks it passes.
Checking your sign-in…