Build a Promise from Scratch
advancedImplement a spec-faithful Promise with then chaining, state transitions, and async resolution.
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.
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:
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.
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[current], preserving input order regardless of completion order.() => 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.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.)
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.
[], not hang. (Same edge case as Promise.all.)task().then never runs. Wrap the call in Promise.resolve().then(task) or try/catch to route it to reject.AbortSignal, skip queued tasks, and pass the signal into each task. See AbortController & cancellation.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.
Implement a spec-faithful Promise with then chaining, state transitions, and async resolution.
Implement Promise.all: aggregate results in order, fail fast on the first rejection.
Implement allSettled: wait for every promise and report per-promise status objects.
Implement Promise.any with AggregateError semantics: first success wins, all failures reject.