Lesson 78 of 80
`Promise.all` runs everything at once. Sometimes 'everything at once' is 10,000 requests and a rate-limited API. Here's the p-limit-style queue that fixes that.
18 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
You have 500 URLs to fetch. Promise.all(urls.map(fetch)) fires all 500 at once — the browser queues most of them anyway (6 connections per host is typical), and a real API will just start returning 429s. What you actually want is: run N at a time, and the moment one finishes, start the next one, until the queue is empty. That's concurrency limiting, and it's the core idea behind libraries like p-limit.
function createLimiter(maxConcurrent) {
let active = 0;
const queue = [];
const runNext = () => {
if (active >= maxConcurrent || queue.length === 0) return;
active++;
const { task, resolve, reject } = queue.shift();
task()
.then(resolve, reject)
.finally(() => {
active--;
runNext(); // one slot freed up — pull the next waiting task
});
};
return function limit(task) {
return new Promise((resolve, reject) => {
queue.push({ task, resolve, reject });
runNext();
});
};
}
const limit = createLimiter(3); // never more than 3 in flight
const results = await Promise.all(
urls.map((url) => limit(() => fetch(url).then((r) => r.json())))
);
The mechanism is a counter (active) plus a waiting line (queue). limit(task) doesn't run task — it wraps it in a promise, pushes it onto the queue, and calls runNext. runNext only actually starts a task if there's a free slot (active < maxConcurrent); every time a running task finishes, .finally decrements active and calls runNext again, pulling the next queued task into the freed slot. Promise.all around the whole thing still gives you one promise that resolves when everything's done — the limiter only changes when each task starts, not how you wait for the results.
await in a loop. Sequential chunking (batch of 100, wait, next batch of 100) never has more than one batch in flight and wastes time when task durations vary — a concurrency limiter keeps exactly maxConcurrent tasks running continuously, starting a new one the instant any slot frees, not just at chunk boundaries..finally (or only handling .then) means a failed task never decrements active, and the queue silently stalls forever with fewer effective slots than you configured — that bug is quiet because everything looks like it's "just running slow."limit() gets called from an ongoing stream (e.g. on every scroll event), you also need a cap on queue length, not just on concurrency, or memory grows without bound.Throttle and debounce cut down how often one function fires over time. This queue caps how many async operations run at the same time — a different axis of the same problem: protecting a downstream system from more load than it can take. Promise.all/allSettled are the "run everything, wait for all" primitive this builds on top of; the limiter is what you reach for the moment "everything" is too much.