A worker pool reads the next index from a shared cursor, awaits one fetch, and writes the result (or caught error) to that slot. Because each index is handled by exactly one worker, Promise.all over limit workers preserves input order and caps concurrency.
type Profile = { id: string /* ... */ };
async function fetchProfiles(
ids: string[],
limit = 5,
): Promise<Array<Profile | Error>> {
const out: Array<Profile | Error> = new Array(ids.length);
let cursor = 0;
async function worker(): Promise<void> {
while (cursor < ids.length) {
const i = cursor++;
try {
const r = await fetch(`/api/users/${ids[i]}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
out[i] = (await r.json()) as Profile;
} catch (e) {
out[i] = e instanceof Error ? e : new Error(String(e));
}
}
}
await Promise.all(Array.from({ length: limit }, worker));
return out;
}
// usage
const results = await fetchProfiles(['u1','u2','u3','u4','u5'], 3);
results.forEach((r, i) =>
r instanceof Error ? console.error(i, r) : console.log(i, r));
Rejections are normalized to Error, so callers can branch on instanceof Error uniformly. Excess workers (limit > ids.length) simply no-op, and an empty input returns []. Use a sensible limit relative to your API/DNS limits.