advanced

Implement a Promise Pool: Concurrency Limiting in JavaScript

Implement a promise pool that runs async tasks with a concurrency limit — the p-limit interview question asked at Meta, Stripe, and Uber, with worked solutions.


The question

You have 1,000 URLs to fetch, but you may only have 5 requests in flight at once. Implement promisePool(tasks, limit) that resolves with all results in order.

This is the single most common senior promise question, because it sits exactly between the extremes candidates already know:

  • All in series — limit = 1. Safe, slow.
  • All in parallel — limit = ∞. Fast, melts the API.
  • The pool — limit = K. What production code actually does.

Note it's also different from batching: a batch of 5 waits for all 5 to finish before starting the next 5. A pool starts a new task the moment any slot frees — strictly better utilization. Interviewers often ask you to articulate exactly this difference.

Solution 1: worker loop (the clean one)

Spawn limit workers that pull tasks from a shared cursor. Elegant, no external state machine:

async function promisePool(tasks, limit) {
  const results = new Array(tasks.length);
  let nextIndex = 0;
 
  async function worker() {
    while (nextIndex < tasks.length) {
      const current = nextIndex++;      // claim a task (sync — no race on one thread)
      results[current] = await tasks[current]();
    }
  }
 
  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    () => worker()
  );
 
  await Promise.all(workers);
  return results;
}

Why this works — and what to narrate:

  • nextIndex++ is safe without locks because JavaScript is single-threaded; interleaving only happens at await points (see the event loop).
  • Results land at results[current], preserving input order regardless of completion order.
  • Tasks are functions returning promises (() => fetch(url)), not promises. A promise is already running; a pool must control when work starts. Saying this sentence is often the difference between pass and fail.

Fail-fast vs collect-errors

As written, a single rejection rejects the pool (via Promise.all) but already-started tasks keep running. For allSettled semantics, wrap the body:

results[current] = await tasks[current]().then(
  (value) => ({ status: "fulfilled", value }),
  (reason) => ({ status: "rejected", reason })
);

(Compare with implementing Promise.allSettled.)

Solution 2: p-limit style (the reusable one)

The follow-up is usually: "Now make it a reusable limit function like the p-limit package." This needs a queue of deferreds — a perfect use for Promise.withResolvers:

function createLimiter(concurrency) {
  let active = 0;
  const queue = [];
 
  function tryNext() {
    if (active >= concurrency || queue.length === 0) return;
    active++;
    const { task, resolve, reject } = queue.shift();
    task().then(resolve, reject).finally(() => {
      active--;
      tryNext();
    });
  }
 
  return function limit(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    queue.push({ task, resolve, reject });
    tryNext();
    return promise;
  };
}
 
// Usage
const limit = createLimiter(5);
const results = await Promise.all(urls.map((u) => limit(() => fetch(u))));

Each caller gets their own promise for their own task — the limiter is shareable across unrelated call sites, which is what makes this version production-grade.

Edge cases interviewers probe

  • limit ≥ tasks.length — degenerate to plain parallel; both solutions handle it naturally.
  • limit ≤ 0 — decide and state a policy (throw, or clamp to 1).
  • Empty task list — must resolve [], not hang. (Same edge case as Promise.all.)
  • Synchronous throws — a task that throws before returning a promise: task().then never runs. Wrap the call in Promise.resolve().then(task) or try/catch to route it to reject.
  • Cancellation — the strongest follow-up: accept an AbortSignal, skip queued tasks, and pass the signal into each task. See AbortController & cancellation.

Complexity

Time is bounded by the slowest critical path (roughly total work ÷ K for uniform tasks); memory is O(n) for results plus O(K) in-flight. The pool never creates more than K pending promises for task execution — that's the resource guarantee the question is really about.


  • Promise.all

    intermediate

    Implement Promise.all: aggregate results in order, fail fast on the first rejection.

  • Promise.allSettled

    intermediate

    Implement allSettled: wait for every promise and report per-promise status objects.

  • Promise.any

    intermediate

    Implement Promise.any with AggregateError semantics: first success wins, all failures reject.