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.
Write mapWithLimit(items, limit, fn). It calls the async function fn(item, index) for every item in the array items, with at most limit calls running at the same time, and returns a promise of the results, in input order.
Start a new call as soon as one finishes, so limit calls are running for as long as work remains: no batches.
If a call rejects, reject with that error and don't start any more calls. If limit is below 1, reject with a RangeError without calling fn. An empty array fulfills with [].
const pages = await mapWithLimit(urls, 3, (url) => fetchJson(url));
// at most 3 requests at once; pages[i] is the response for urls[i]
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms, "waited " + ms));
await mapWithLimit([300, 100, 200], 2, wait);
// at 0 ms: wait(300) and wait(100) start
// at 100 ms: wait(100) is done, so wait(200) starts right away
// at 300 ms: wait(300) and wait(200) are done, and it fulfills with
// ["waited 300", "waited 100", "waited 200"]
const clock = useFakeTimers();
const fn = (ms) => new Promise((resolve) => setTimeout(resolve, ms, "done in " + ms));
let result;
mapWithLimit([300, 100, 200], 2, fn).then((value) => {
result = value;
});
await clock.tickAsync(1000);
expect(result).toEqual(["done in 300", "done in 100", "done in 200"]);
const clock = useFakeTimers();
let running = 0;
let most = 0;
const fn = (ms) => {
running += 1;
most = Math.max(most, running);
return new Promise((resolve) =>
setTimeout(() => {
running -= 1;
resolve(ms);
}, ms),
);
};
mapWithLimit([50, 100, 30, 80, 20, 60], 2, fn);
await clock.tickAsync(1000);
expect(most).toBeLessThanOrEqual(2);
const clock = useFakeTimers();
const start = clock.now();
const startedAt = [];
const fn = (ms) => {
startedAt.push(clock.now() - start);
return new Promise((resolve) => setTimeout(resolve, ms, ms));
};
mapWithLimit([100, 300, 100, 100], 2, fn);
await clock.tickAsync(1000);
expect(startedAt).toEqual([0, 0, 100, 200]);
const fn = spy(async (item) => item);
await mapWithLimit(["a", "b", "c"], 2, fn);
expect(fn.calls.map((call) => call.args)).toEqual([
["a", 0],
["b", 1],
["c", 2],
]);
const clock = useFakeTimers();
const error = new Error("item 1 failed");
const fn = (ms, index) =>
new Promise((resolve, reject) => setTimeout(() => (index === 1 ? reject(error) : resolve(ms)), ms));
let reason;
mapWithLimit([100, 50, 100, 100], 2, fn).catch((e) => {
reason = e;
});
await clock.tickAsync(1000);
expect(reason).toBe(error);
const clock = useFakeTimers();
const fn = spy((ms, index) =>
new Promise((resolve, reject) => setTimeout(() => (index === 1 ? reject(new Error("failed")) : resolve(ms)), ms)),
);
mapWithLimit([100, 50, 100, 100, 100], 2, fn).catch(() => {}); // it rejects; this test only counts the calls
await clock.tickAsync(1000);
expect(fn).toHaveBeenCalledTimes(2);
let result;
mapWithLimit([], 3, async (item) => item).then((value) => {
result = value;
});
await flush();
expect(result).toEqual([]);
const fn = spy(async (item) => item);
await expect(mapWithLimit([1, 2], 0, fn)).rejects.toThrow(RangeError);
expect(fn).not.toHaveBeenCalled();
const fn = spy(async (item) => item * 2);
await expect(mapWithLimit([1, 2], 10, fn)).resolves.toEqual([2, 4]);
expect(fn).toHaveBeenCalledTimes(2);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Map with a concurrency limit. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.