Yieldless
Recipes

Bounded Batch Work

Process lists of API, filesystem, or subprocess work without unbounded parallelism.

This recipe is for the common "do this for every item" path: refresh every repo, fetch every avatar, resize every image, or inspect every file. The work is independent, but starting it all at once can make a user's machine or an upstream service miserable.

Use mapLimit() when you want tuple errors, shared cancellation, stable output order, and a hard ceiling on active work.

Refresh several repositories

import { mapLimit } from "yieldless/all";
import { runCommandSafe } from "yieldless/node";

interface Repository {
  readonly id: string;
  readonly path: string;
}

export async function refreshRepositories(
  repositories: readonly Repository[],
  signal: AbortSignal,
) {
  return await mapLimit(
    repositories,
    async (repository, _index, itemSignal) => {
      const [fetchError] = await runCommandSafe("git", ["fetch", "--prune"], {
        cwd: repository.path,
        signal: itemSignal,
      });

      if (fetchError) {
        return [fetchError, null] as const;
      }

      const [statusError, status] = await runCommandSafe(
        "git",
        ["status", "--short"],
        {
          cwd: repository.path,
          signal: itemSignal,
        },
      );

      if (statusError) {
        return [statusError, null] as const;
      }

      return [
        null,
        {
          id: repository.id,
          status: status.stdout,
        },
      ] as const;
    },
    {
      concurrency: 3,
      signal,
    },
  );
}

Why this is friendlier

  • Output order matches repositories, so UI code does not need to sort the result back into place.
  • The first tuple error aborts in-flight work and stops starting new items.
  • A parent AbortSignal can cancel the whole batch when the user navigates away.
  • concurrency is explicit, so expensive work stays polite by default.

Good variation: stream the input

If repositories come from an async source, use yieldless/iterable instead of building a large array first.

import { mapAsyncLimit } from "yieldless/iterable";

const [error, summaries] = await mapAsyncLimit(
  readRepositories(workspacePath),
  (repository, _index, signal) => inspectRepository(repository, signal),
  {
    concurrency: 3,
    signal,
  },
);

Avoid: unbounded fan-out

await Promise.all(
  repositories.map((repository) =>
    runCommandSafe("git", ["fetch"], { cwd: repository.path }),
  ),
);

That can launch dozens or hundreds of subprocesses. Use mapLimit() or mapAsyncLimit() so the user’s machine stays responsive.

On this page