typescript 78 lines · 2 tabs

Simple concurrency limiter for batch operations

Shared by codesnips Jan 2026
2 tabs
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;
  settle?: boolean;
}

export async function mapWithConcurrency<T, R>(
  items: readonly T[],
  worker: (item: T, index: number) => Promise<R>,
  options: ConcurrencyOptions
): Promise<Array<R | Settled<R>>> {
  const { limit, settle = false } = options;
  if (items.length === 0) return [];

  const results = new Array<R | Settled<R>>(items.length);
  let cursor = 0;

  async function runWorker(): Promise<void> {
    while (cursor < items.length) {
      const index = cursor++;
      const item = items[index];
      if (!settle) {
        results[index] = await worker(item, index);
        continue;
      }
      try {
        results[index] = { status: 'fulfilled', value: await worker(item, index) };
      } catch (reason) {
        results[index] = { status: 'rejected', reason };
      }
    }
  }

  const poolSize = Math.min(limit, items.length);
  const pool = Array.from({ length: poolSize }, () => runWorker());
  await Promise.all(pool);
  return results;
}
2 files · typescript Explain with highlit

Firing off hundreds of async tasks at once with Promise.all is a common way to overwhelm a database, exhaust file descriptors, or trip an upstream API's rate limits. The safer pattern is a bounded concurrency limiter: a small number of workers pull from a shared queue so no more than n operations run in flight at any moment. This snippet shows a self-contained limiter and a realistic batch job that uses it.

In limitConcurrency.ts, mapWithConcurrency accepts an array of items, a limit, and a worker that maps each item to a Promise. Instead of scheduling every task immediately, it spins up exactly limit long-lived workers that share a single cursor index. Each worker loops, atomically grabbing the next index with cursor++ and awaiting the worker call, writing results back into a positionally-indexed results array so output order matches input order regardless of completion order. Because JavaScript is single-threaded for the synchronous cursor++, no lock is needed — the increment can't interleave.

A key design choice is that results are collected per-index rather than by pushing, which preserves ordering, and failures are captured through a settle option. When settle is true the worker records a { status: 'rejected' } entry instead of throwing, mirroring Promise.allSettled semantics so one bad item doesn't abort the whole batch. When it's false the rejection propagates and the remaining workers naturally stop pulling new work once the outer Promise.all rejects.

The ConcurrencyOptions type and generics keep the helper fully reusable across item and result types. Math.min(limit, items.length) avoids spawning idle workers for tiny inputs, and an empty array short-circuits.

In imageBatch.ts, processUploads puts the limiter to work resizing images with a concurrency of four. It uses settle: true so a single corrupt file yields a recorded failure rather than losing the whole batch, then partitions the settled results into successes and failures for reporting. This is the everyday shape of the pattern: throttle the fan-out, keep going on partial failure, and reconcile results at the end. The main trade-off is throughput versus resource pressure — a higher limit finishes faster but risks the very overload the limiter exists to prevent, so the number should be tuned to the slowest downstream dependency.


Related snips

Share this code

Here's the card — post it anywhere.

Simple concurrency limiter for batch operations — share card
Link copied