Skip to content

parallel() vs pipeline()

packages/core/src/runtime.ts

Both run work concurrently — the difference is the barrier. parallel() awaits everything before returning. pipeline() has no barrier: each item flows through all stages independently, so item A can be in stage 2 while item B is still in stage 1. Toggle below and watch the wall-clock.

item 0
stage1
stage2
item 1
stage1
stage2
item 2
stage1
stage2
wall
5 units — slowest single chain, nothing waits

What you're seeing

In parallel-then-parallel (two parallel() calls in sequence), stage 2 can't begin until the slowest stage-1 finishes — that red line is the barrier, and fast items sit idle against it. In pipeline(), each item's stage 2 starts the moment its own stage 1 is done. Wall-clock becomes the slowest single chain instead of the sum of slowest-per-stage.

js
// BARRIER: awaits everything. A throw becomes null (never rejects the group).
const parallel = (thunks) =>
  Promise.all(thunks.map((t) => t().catch(() => null)));

// NO BARRIER: each item flows through all stages independently.
const pipeline = (items, ...stages) =>
  Promise.all(items.map(async (item, index) => {
    let prev = item;
    try { for (const s of stages) prev = await s(prev, item, index); return prev; }
    catch { return null; }   // a thrown stage drops THAT item, keeps the rest
  }));

When to reach for which

  • Default to pipeline(). Most multi-stage work (find → verify, draft → critique) has no cross-item dependency, so a barrier just wastes the fast items' time.
  • Reach for a barrier (parallel()) only when stage N genuinely needs all of stage N−1 — e.g. dedup/merge across the full result set, or an early-exit on a zero count.
  • Both swallow failures into null. Filter with .filter(Boolean) before using the results.

Every concurrent branch still passes through the semaphore, so "100 thunks" never means "100 agents at once."

Built from the source of @workflow/{schema,core,adapters,cli,ui}.