Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Build a Parallel Subagent Orchestrator in TypeScript: Fan-Out, Retries, Pipelines
EngineeringJune 8, 202627 min readโ€ข By Michael Eakins

Build a Parallel Subagent Orchestrator in TypeScript: Fan-Out, Retries, Pipelines

A hands-on TypeScript tutorial for building a parallel subagent orchestrator: bounded-concurrency fan-out, automatic retries, schema-validated structured output, and barrier-free pipelines โ€” provider-agnostic, with tests.

Quick Takeaways

What you'll learn in this article

27 min read
Intermediate
  • 1

    Run many of these at once, but not all at once โ€” a fixed concurrency cap.

  • 2

    Keep results in input order, even though they finish out of order.

  • 3

    Retry failures up to a budget, treating a malformed-output as a failure.

  • 4

    Never let one bad subagent sink the batch โ€” collect outcomes, don't throw.

  • 5

    Support pipelines, where each item flows through several stages without waiting for the whole batch to finish a stage first.

Keep reading for detailed implementation, code examples, and real-world results

The headline feature of this spring's frontier-model releases was not a benchmark. It was orchestration. Anthropic's Claude Opus 4.8 shipped parallel-subagent workflows as a first-class capability; the other labs followed with their own versions of the same idea. The pitch is seductive: instead of one model grinding through a long task token by token, you fan the work out to a fleet of subagents, each with its own focused context, and gather their results. A review of forty files becomes forty small reviews running at once. A research question becomes eight searches in parallel. The wall-clock cost stops being the sum of the work and starts being the cost of the slowest single piece.

What almost nobody tells you is that the orchestration layer underneath that feature is not complicated. It is a fixed pool of workers pulling from a shared queue, each call retried and validated, results returned in order. You can buy it bundled into a proprietary agent runtime, or you can own it in about a hundred and fifty lines of TypeScript that runs anywhere, talks to any model, and is trivially testable without spending a cent on tokens. This tutorial builds the second thing.

By the end you will have a small, dependency-light orchestrator with four public primitives: mapWithConcurrency (the scheduler), parallel (fan tasks out and settle them), pipeline (thread items through stages with no barrier), and llmTask (turn a prompt into a retryable, schema-validated task). Every piece is covered by tests, and the whole thing is provider-agnostic โ€” you wire in Anthropic, OpenAI, a local model, or a deterministic mock through a single one-line interface.

The companion code lives at CrashBytes/ByteSizedExamples/parallel-subagent-orchestrator. Clone it, run npm install && npm test, and follow along.

What we are actually building

Before any code, it helps to be precise about the shape of the problem. A "subagent" in this context is just an asynchronous unit of work that may fail and may need its output checked. It might call a model, hit a tool, or run a local computation. The orchestrator's job is narrow and unglamorous:

  • Run many of these at once, but not all at once โ€” a fixed concurrency cap.
  • Keep results in input order, even though they finish out of order.
  • Retry failures up to a budget, treating a malformed-output as a failure.
  • Never let one bad subagent sink the batch โ€” collect outcomes, don't throw.
  • Support pipelines, where each item flows through several stages without waiting for the whole batch to finish a stage first.

That is the entire contract. Everything below is an implementation of those five bullets, built up one layer at a time.

A quick note on why the concurrency cap matters more than it looks. The naive approach โ€” Promise.all(tasks.map(runOne)) โ€” launches everything simultaneously. With ten tasks that is fine. With four hundred it will open four hundred connections to your model provider at once, blow straight through your rate limit, and earn you a wall of 429s. Bounded concurrency is not an optimization; it is the difference between a working orchestrator and a rate-limit incident.

Wall-clock to run 40 one-second subagents (illustrative)

Wall-clock to run 40 one-second subagents (illustrative)
strategyseconds
Sequential40
Promise.all (unbounded)4
Bounded pool (limit 8)6

The unbounded version is fastest on paper and a liability in production. The bounded pool gives up a little wall-clock to stay inside the limits that actually govern whether your requests succeed. That trade is the whole reason this tutorial exists.

Part 1: the scheduling primitive

Everything starts with one function. mapWithConcurrency runs a worker over a list of items with at most limit promises in flight at a time, and returns the results in input order. Create src/concurrency.ts:

/**
 * Run `worker` over `items` with at most `limit` promises in flight at once.
 *
 * Results come back in input order, regardless of which item finishes first.
 * This is the scheduling primitive every other piece of the orchestrator is
 * built on: a fixed pool of workers pulling from a shared cursor.
 */
export async function mapWithConcurrency<T, R>(
  items: readonly T[],
  limit: number,
  worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
  if (limit < 1) {
    throw new Error('concurrency limit must be at least 1')
  }

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

  async function runner(): Promise<void> {
    while (true) {
      const index = cursor
      cursor += 1
      if (index >= items.length) {
        return
      }
      results[index] = await worker(items[index], index)
    }
  }

  const poolSize = Math.min(limit, items.length)
  const runners = Array.from({ length: poolSize }, () => runner())
  await Promise.all(runners)
  return results
}

The mechanism is worth slowing down on, because it is the cleverest part of the whole library and it is only fifteen lines. We create exactly poolSize runner functions. Each runner loops: it grabs the next index from the shared cursor, increments the cursor, and processes that item. When the cursor runs past the end of the array, the runner returns. Because every runner shares the same cursor, they cooperatively drain the work list โ€” no runner ever processes the same item twice, and the moment any runner finishes an item it immediately pulls the next one. There is never more than poolSize items being worked at once, and there is never an idle worker while work remains.

Notice we write results[index] = ... rather than pushing to an array. That is what preserves input order: result i always lands at slot i, no matter how the timing shakes out. A subagent that finishes last but was first in the list still ends up first in the output.

This is the part people most often get subtly wrong when they hand-roll it. The common mistake is to chunk the work โ€” run items 0 through 7, await all of them, then run 8 through 15, and so on. That looks like bounded concurrency but it is not: if item 3 takes ten seconds and the rest take one, the entire first chunk waits ten seconds before the second chunk can even start. Seven workers sit idle staring at the slow one. The shared-cursor pool has no such barrier โ€” the instant a fast worker frees up, it takes the next item, slow neighbor or not.

Throughput vs concurrency limit โ€” diminishing returns past the bottleneck (illustrative)

Throughput vs concurrency limit โ€” diminishing returns past the bottleneck (illustrative)
limitthroughput
11
22
43.9
87.6
1612
3212.5

The chart above is the reason limit is a parameter and not a constant. Raising it helps right up until you hit the real bottleneck โ€” the provider's rate limit, your CPU, the downstream service โ€” and then it flattens or gets worse as contention sets in. The right value is empirical and workload-specific, which is exactly why we expose it.

Testing the scheduler

A scheduler you cannot prove is a scheduler you do not trust. Two properties matter: results stay in order, and the limit is never exceeded. Create test/concurrency.test.ts:

import { describe, expect, it } from 'vitest'
import { mapWithConcurrency } from '../src/concurrency.js'

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

describe('mapWithConcurrency', () => {
  it('preserves input order regardless of completion order', async () => {
    const out = await mapWithConcurrency([30, 10, 20], 3, async ms => {
      await sleep(ms)
      return ms
    })
    expect(out).toEqual([30, 10, 20])
  })

  it('never exceeds the concurrency limit', async () => {
    let inFlight = 0
    let peak = 0
    const items = Array.from({ length: 12 }, (_, i) => i)

    await mapWithConcurrency(items, 3, async i => {
      inFlight += 1
      peak = Math.max(peak, inFlight)
      await sleep(5)
      inFlight -= 1
      return i
    })

    expect(peak).toBeLessThanOrEqual(3)
    expect(peak).toBeGreaterThan(1)
  })

  it('passes the index to the worker', async () => {
    const out = await mapWithConcurrency(
      ['a', 'b', 'c'],
      2,
      async (v, i) => `${i}:${v}`
    )
    expect(out).toEqual(['0:a', '1:b', '2:c'])
  })

  it('rejects a limit below 1', async () => {
    await expect(mapWithConcurrency([1], 0, async x => x)).rejects.toThrow(
      /at least 1/
    )
  })
})

The "never exceeds the limit" test is the important one. We track a live inFlight counter, bump a peak watermark on every entry, and assert the peak never crossed three. The first item sleeps longest in the order test precisely so that a buggy push-based implementation would reorder the output and fail. These are the tests that catch the chunking mistake described above.

Advertisement

Part 2: tasks, retries, and validation

A bare worker function is not enough for real subagents, because real subagents fail in two distinct ways. They throw โ€” a network blip, a 429, a timeout โ€” and they lie: a model returns text that is confidently wrong-shaped, missing the field you asked for, wrapped in an apology. We want to treat both the same way: try again, up to a budget. Define the vocabulary in src/types.ts:

/**
 * A validator with a Zod-compatible `parse` that throws on invalid input.
 * Any `z.object({...})` schema satisfies this interface, but so does a
 * hand-rolled guard โ€” the orchestrator never imports Zod itself.
 */
export interface Validator<T> {
  parse(value: unknown): T
}

/** One unit of work handed to the orchestrator. */
export interface Task<Output> {
  /** Human-readable label, surfaced in results and logs. */
  readonly name: string
  /** Do the work. May call an LLM, hit a tool, or compute locally. */
  run(): Promise<Output>
  /**
   * Optional schema. When present, the orchestrator parses each attempt's
   * output through it and treats a parse failure as a retryable error โ€” the
   * model gets another try to produce something that validates.
   */
  readonly validate?: Validator<Output>
}

/** The outcome of running a single task, after any retries. */
export interface Settled<Output> {
  readonly name: string
  readonly status: 'fulfilled' | 'rejected'
  readonly value?: Output
  readonly reason?: Error
  /** How many attempts were made (1 = succeeded on the first try). */
  readonly attempts: number
}

/** Tuning knobs shared by `parallel` and `pipeline`. */
export interface RunOptions {
  /** Extra attempts after the first failure. Default 2 (3 tries total). */
  readonly maxRetries?: number
  /** Maximum tasks/items in flight at once. Default 4. */
  readonly concurrency?: number
}

The Validator<T> interface deserves a comment. We define our own one-method interface rather than importing Zod into the core library. Zod's z.object({...}) schemas satisfy it for free โ€” parse(value: unknown): T is exactly Zod's signature โ€” but so does a five-line hand-rolled type guard. This keeps the orchestrator's runtime dependency surface at zero and lets callers bring whatever validation library they already use. Dependency discipline in a library is the same discipline as concurrency limits in a client: take only what you need.

The other deliberate choice is Settled. We are modeling the same idea as Promise.allSettled, not Promise.all: a task does not throw out of the batch, it resolves into a record that says whether it succeeded, what it produced, and how many attempts it took. That attempts field is not decoration โ€” in production it is the single most useful signal you have for spotting a model or endpoint that is silently degrading, retrying its way to success on every call while your latency quietly doubles.

Now the engine. Create src/orchestrator.ts and start with the single-task runner:

import { mapWithConcurrency } from './concurrency.js'
import type { RunOptions, Settled, Task } from './types.js'

const DEFAULT_MAX_RETRIES = 2
const DEFAULT_CONCURRENCY = 4

function toError(value: unknown): Error {
  return value instanceof Error ? value : new Error(String(value))
}

/**
 * Run a single task with bounded retries. A thrown error and a schema
 * validation failure are treated the same way: retry until success or until
 * the attempt budget is exhausted, then settle as `rejected`.
 */
export async function runTask<Output>(
  task: Task<Output>,
  maxRetries: number = DEFAULT_MAX_RETRIES
): Promise<Settled<Output>> {
  let lastError: Error | undefined
  const totalAttempts = maxRetries + 1

  for (let attempt = 1; attempt <= totalAttempts; attempt += 1) {
    try {
      const raw = await task.run()
      const value = task.validate ? task.validate.parse(raw) : raw
      return { name: task.name, status: 'fulfilled', value, attempts: attempt }
    } catch (error) {
      lastError = toError(error)
    }
  }

  return {
    name: task.name,
    status: 'rejected',
    reason: lastError,
    attempts: totalAttempts,
  }
}

Read the loop body closely. We call task.run(), then โ€” if a validator is present โ€” we run the result through task.validate.parse(). Both the run() call and the parse() call are inside the same try. That is the whole trick: a thrown network error and a schema-rejection both land in the catch, both set lastError, and both fall through to the next iteration of the loop. The model gets another attempt to produce something that validates, with no special-casing. A validation failure is just an error that happens to originate from your schema instead of the network.

This unification is what makes structured-output retries reliable. Plenty of agent code treats "the API call failed" and "the API returned garbage" as separate concerns with separate handling. They are the same concern: the attempt did not produce a usable result, so try again. Collapsing them into one code path removes an entire category of bug where malformed output silently propagates because only thrown errors were being retried.

Effective success rate at 80% per-attempt reliability (illustrative)

Effective success rate at 80% per-attempt reliability (illustrative)
attemptssuccess
1 try80
2 tries96
3 tries99.2

The arithmetic of retries is more forgiving than intuition suggests. If a single attempt succeeds eighty percent of the time, two attempts push you to ninety-six percent and three to over ninety-nine, because the only way to fail is to fail every independent try. This is why a default of two retries (three total attempts) is a sensible baseline for model calls: it converts a flaky endpoint into a reliable one without papering over a genuinely broken task, which will still fail all three times and settle as rejected with its last error attached.

A caveat the chart hides: retries only help when failures are independent. If your prompt is malformed in a way that guarantees bad output, all three attempts fail identically and you have spent triple the tokens to learn what one attempt told you. Retries are insurance against transient failure, not a substitute for a prompt that works.

Part 3: fanning out with parallel

With runTask in hand, parallel is almost trivial โ€” it is mapWithConcurrency over a list of tasks, running each through runTask. Add it to src/orchestrator.ts:

/**
 * Fan a batch of tasks out across subagents, running at most
 * `options.concurrency` at a time. This call NEVER rejects: each task settles
 * into a `Settled` record, so one subagent blowing up can't sink the batch.
 * Filter on `status === 'fulfilled'` to collect the winners.
 */
export async function parallel<Output>(
  tasks: ReadonlyArray<Task<Output>>,
  options: RunOptions = {}
): Promise<Array<Settled<Output>>> {
  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
  const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY
  return mapWithConcurrency(tasks, concurrency, task =>
    runTask(task, maxRetries)
  )
}

The signature is the design. parallel returns Array<Settled<Output>> and never rejects, which means the caller is forced to confront the possibility of partial failure. There is no happy path where you pretend every subagent succeeded. You get an array of outcomes, you filter for the fulfilled ones, and you decide explicitly what to do about the rejected ones โ€” log them, retry the batch, fail loudly, or proceed with what you have. In a fleet of subagents, partial failure is not an exception; it is Tuesday. An API that hides it is an API that will surprise you in production.

The usage pattern is always the same two lines:

const settled = await parallel(tasks, { concurrency: 8, maxRetries: 2 })
const results = settled.filter(s => s.status === 'fulfilled').map(s => s.value)

Here is the property test that pins down the isolation guarantee โ€” one task throws, and the batch still returns clean results for its neighbors:

import { parallel, pipeline, runTask } from '../src/orchestrator.js'
import type { Task } from '../src/types.js'

describe('parallel', () => {
  it('runs every task and isolates failures', async () => {
    const tasks: Task<number>[] = [
      { name: 'a', run: async () => 1 },
      {
        name: 'b',
        run: async () => {
          throw new Error('b failed')
        },
      },
      { name: 'c', run: async () => 3 },
    ]
    const settled = await parallel(tasks, { maxRetries: 0 })
    expect(settled.map(s => s.status)).toEqual([
      'fulfilled',
      'rejected',
      'fulfilled',
    ])
    expect(settled[0].value).toBe(1)
    expect(settled[2].value).toBe(3)
    expect(settled[1].reason?.message).toBe('b failed')
  })
})

Task b throws on every attempt; tasks a and c are untouched and their values arrive at the correct indices. This is the behavior you want when a single file in a forty-file review trips an error: thirty-nine reviews come back fine and the one failure is isolated, labeled, and surfaced rather than aborting the run.

A realistic 40-subagent batch: most succeed, some retry, a couple fail (illustrative)

A realistic 40-subagent batch: most succeed, some retry, a couple fail (illustrative)
NameValue

Part 4: LLM-backed subagents

So far nothing in the library knows what a language model is, which is exactly how it should be โ€” the orchestrator schedules and retries, and stays ignorant of what the work actually is. The bridge to real models lives in one small file. Create src/llm.ts:

import type { Task, Validator } from './types.js'

/**
 * Anything that turns a prompt into completion text. Wrap your provider SDK
 * here โ€” Anthropic, OpenAI, a local model, or a deterministic mock in tests.
 * Keeping the orchestrator behind this one-line interface is what makes it
 * provider-agnostic and trivially testable without network access.
 */
export type CompletionFn = (prompt: string) => Promise<string>

/**
 * Pull the first JSON object or array out of a (possibly fenced, possibly
 * prose-wrapped) completion. Models love to say "Sure! Here's the JSON:"
 * before the payload and add a closing remark after it; this finds the value.
 */
export function extractJson(text: string): string {
  const body = stripCodeFence(text)

  const start = firstIndexOf(body, '{', '[')
  if (start === -1) {
    throw new Error('no JSON value found in completion')
  }

  const open = body[start]
  const close = open === '{' ? '}' : ']'
  const end = body.lastIndexOf(close)
  if (end <= start) {
    throw new Error('unterminated JSON value in completion')
  }

  return body.slice(start, end + 1)
}

/**
 * Return the contents of the first ``` fenced block, or the whole string when
 * there is no fence. Plain string scanning, deliberately not a regex: a lazy
 * `[\s\S]*?` between two fences is exactly the backtracking shape that trips
 * ReDoS scanners, and there is no need for it here.
 */
function stripCodeFence(text: string): string {
  const open = text.indexOf('```')
  if (open === -1) {
    return text
  }
  // Skip the optional language tag (e.g. ```json) on the opening-fence line.
  const lineEnd = text.indexOf('\n', open)
  const contentStart = lineEnd === -1 ? open + 3 : lineEnd + 1
  const close = text.indexOf('```', contentStart)
  return close === -1
    ? text.slice(contentStart)
    : text.slice(contentStart, close)
}

/** Index of whichever of `a`/`b` appears first, or -1 if neither is present. */
function firstIndexOf(text: string, a: string, b: string): number {
  const ia = text.indexOf(a)
  const ib = text.indexOf(b)
  if (ia === -1) return ib
  if (ib === -1) return ia
  return Math.min(ia, ib)
}

/**
 * Build a `Task` backed by an LLM completion.
 *
 * With a `validate` schema the completion is JSON-extracted and parsed, so a
 * malformed response surfaces as a retryable error (see `runTask`). Without a
 * schema the raw text is returned. Hand the resulting tasks to `parallel` to
 * run a fleet of subagents at once.
 */
export function llmTask<Output>(params: {
  name: string
  prompt: string
  complete: CompletionFn
  validate?: Validator<Output>
}): Task<Output> {
  const { name, prompt, complete, validate } = params
  return {
    name,
    validate,
    async run(): Promise<Output> {
      const text = await complete(prompt)
      if (!validate) {
        return text as unknown as Output
      }
      return JSON.parse(extractJson(text)) as Output
    },
  }
}

Two ideas are doing the work here. The first is CompletionFn, a type alias for (prompt: string) => Promise<string>. That is the entire surface area between this orchestrator and the outside world of models. Anthropic's SDK, OpenAI's, Ollama running on your laptop, or a mock that returns a canned string in a test โ€” all of them collapse to that one signature. Your business logic never imports a provider SDK; it imports CompletionFn and someone wires the real thing in at the edge. This is dependency inversion, and it is the difference between a test suite that runs in three hundred milliseconds with no network and one that you cannot run on a plane.

The second idea is extractJson, which exists because of a small, persistent indignity of working with language models: you ask for JSON and you get "Sure! Here's the JSON you requested:" followed by a fenced code block followed by "Let me know if you'd like any changes!" The function strips a Markdown fence if one is present, then finds the first opening brace or bracket and the last matching closer, and returns the slice between them. It is not a parser โ€” JSON.parse is the parser โ€” it is a locator that tolerates the conversational packaging models insist on adding. When the model produces no JSON at all, it throws, which means the attempt is retried, which is exactly right.

When validate is present, llmTask.run extracts and parses the JSON, then hands the object back; runTask then runs it through the validator a second time. That two-step โ€” parse the text, validate the shape โ€” is what lets you ask a fleet of subagents for structured data and trust what comes back, because anything that fails either step gets retried. Here is the whole thing working with a real Zod schema:

import { z } from 'zod'
import { extractJson, llmTask } from '../src/llm.js'
import { parallel, runTask } from '../src/orchestrator.js'

it('parses and validates structured output with a real Zod schema', async () => {
  const Ticket = z.object({ label: z.string(), priority: z.number().int() })
  const task = llmTask({
    name: 'classify',
    prompt: 'classify this ticket',
    complete: async () => '```json\n{"label":"billing","priority":2}\n```',
    validate: Ticket,
  })
  const settled = await runTask(task)
  expect(settled.status).toBe('fulfilled')
  expect(settled.value).toEqual({ label: 'billing', priority: 2 })
})

The complete function in the test returns a fenced JSON block โ€” exactly the kind of conversational packaging extractJson is built to survive โ€” and the Zod schema validates the parsed shape. Swap that mock complete for a real provider call and nothing else changes. That is the payoff of routing everything through CompletionFn: the test and production differ by one function, and it is the one function that touches the network.

Fanning a batch of these out is the canonical use case โ€” classify a hundred support tickets, review a directory of files, summarize a stack of documents:

const Label = z.object({ label: z.string() })

const tasks = tickets.map(ticket =>
  llmTask({
    name: `classify:${ticket.id}`,
    prompt: `Classify this ticket. Return JSON {"label": "..."}.\n\n${ticket.body}`,
    complete,
    validate: Label,
  })
)

const settled = await parallel(tasks, { concurrency: 8, maxRetries: 2 })

A hundred tickets, eight at a time, each retried up to twice and validated against the schema, results in order, failures isolated. That is a production-grade classification pass in a dozen lines, and the orchestrator that powers it still does not know what a ticket is.

Advertisement

Part 5: pipelines without barriers

Fan-out handles the case where every subagent does the same kind of work. The other common shape is a multi-stage flow: each item gets reviewed, then each review gets verified, then each verified finding gets summarized. The tempting implementation is a barrier between stages โ€” review everything, then verify everything, then summarize everything. It is also the wrong one, and the reason is the same chunking problem from Part 1, one level up.

If you barrier between stages, the verify stage cannot begin until the slowest review finishes. Every fast review sits done-but-waiting while one slow review holds up the whole cohort. A pipeline removes the barrier: the moment item A's review is done, A flows into verify, even though item B is still being reviewed. Wall-clock becomes the slowest single end-to-end chain, not the sum of the slowest item in each stage. Add pipeline to src/orchestrator.ts:

/** A single transform in a pipeline. Receives the previous stage's output. */
export type Stage<In, Out> = (input: In, index: number) => Promise<Out>

/**
 * Run each item through every stage independently, with NO barrier between
 * stages: item B can still be in stage 1 while item A is already in stage 3.
 * Wall-clock is the slowest single-item chain, not the sum of per-stage maxima.
 * A stage that throws drops just that item to `null`; the rest keep flowing.
 */
export async function pipeline<Out>(
  items: readonly unknown[],
  stages: ReadonlyArray<Stage<any, any>>,
  options: RunOptions = {}
): Promise<Array<Out | null>> {
  const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY
  return mapWithConcurrency(items, concurrency, async (item, index) => {
    let current: unknown = item
    try {
      for (const stage of stages) {
        current = await stage(current, index)
      }
      return current as Out
    } catch {
      return null
    }
  })
}

The implementation reuses the scheduler from Part 1, which is the entire point of having built a scheduler. Each item is one unit of work to mapWithConcurrency, and that unit happens to be "run this item through every stage in sequence." Stage one's output becomes stage two's input becomes stage three's input. Because the scheduler runs these per-item chains concurrently up to the limit, item A can be in its third stage while item B is in its first. There is no global barrier anywhere, because there is no await that waits on the whole cohort โ€” only the per-item loop.

A stage that throws drops that single item to null and skips its remaining stages, leaving every other item's chain untouched. The contract is the same as parallel: one failure is isolated, not fatal. The tests pin both behaviors:

describe('pipeline', () => {
  it('threads each item through every stage', async () => {
    const out = await pipeline<number>(
      [1, 2, 3],
      [async (n: number) => n + 1, async (n: number) => n * 10],
      { concurrency: 2 }
    )
    expect(out).toEqual([20, 30, 40])
  })

  it('drops a failing item to null without sinking the batch', async () => {
    const out = await pipeline<number>(
      [1, 2, 3],
      [
        async (n: number) => {
          if (n === 2) throw new Error('stage failed for 2')
          return n
        },
        async (n: number) => n * 2,
      ]
    )
    expect(out).toEqual([2, null, 6])
  })
})

In the second test, item 2 throws in the first stage and never reaches the doubling stage, so it lands as null, while items 1 and 3 sail through to 2 and 6. Filter the nulls out at the end and you have your successful chains.

Cumulative wall-clock: barrier between stages vs barrier-free pipeline (illustrative)

Cumulative wall-clock: barrier between stages vs barrier-free pipeline (illustrative)
stagebarrierpipeline
Review103
Verify186
Summarize248

The gap between the two curves is pure waste โ€” fast items waiting on slow neighbors at every stage boundary. On a three-stage flow with any variance in per-item latency, the pipeline finishes meaningfully sooner, and the advantage compounds with each stage you add.

Part 6: wiring a real provider

The mock CompletionFn is what makes the tests fast, but at some point you want real tokens. Because everything routes through one interface, wiring Anthropic is a single adapter function โ€” no changes to the orchestrator, no changes to your task definitions:

import Anthropic from '@anthropic-ai/sdk'
import type { CompletionFn } from 'parallel-subagent-orchestrator'

const client = new Anthropic()

export const complete: CompletionFn = async prompt => {
  const res = await client.messages.create({
    model: 'claude-opus-4-8',
    max_tokens: 1024,
    messages: [{ role: 'user', content: prompt }],
  })
  return res.content.map(b => (b.type === 'text' ? b.text : '')).join('')
}

That complete is the same shape as the mock in the tests. Hand it to llmTask and your subagents are now talking to a frontier model, with the orchestrator's bounded concurrency keeping you inside your rate limit and its retry budget absorbing the occasional 429 or malformed response. Swapping providers โ€” OpenAI, a local Ollama model, a router that picks the cheapest model per task โ€” means writing a different complete, and nothing else.

Finally, the public surface, in src/index.ts:

export { mapWithConcurrency } from './concurrency.js'
export { parallel, pipeline, runTask } from './orchestrator.js'
export type { Stage } from './orchestrator.js'
export { extractJson, llmTask } from './llm.js'
export type { CompletionFn } from './llm.js'
export type { RunOptions, Settled, Task, Validator } from './types.js'

Running it

The companion project uses Vitest and TypeScript with nodenext module resolution โ€” which is why every internal import carries a .js extension even though the files are .ts. From the project directory:

npm install
npm run typecheck   # tsc --noEmit
npm test            # vitest run

You should see all three test files green: the scheduler properties, the runner's retry and validation behavior, and the LLM task's JSON handling. The whole suite runs in well under a second because nothing touches the network โ€” the only "model" in the tests is a function that returns a string.

Test coverage by file in the companion project

Test coverage by file in the companion project
filetests
concurrency.test.ts4
orchestrator.test.ts7
llm.test.ts6

Where to take it next

What you have built is the load-bearing core, and it is deliberately small. A few directions to extend it, in rough order of how often you will reach for them.

First, exponential backoff with jitter. Right now a retry is immediate, which is fine for validation failures but suboptimal for rate-limit errors, where you want to wait โ€” and wait a little randomly, so a thousand subagents that all got 429'd at once do not all retry at the same instant and stampede the endpoint again. A delayMs(attempt) hook in runTask is a ten-line addition.

Second, a token or cost budget. In a long-running fan-out you often want to stop spawning new tasks once you have spent a target number of tokens. Thread a shared counter through parallel and check it before each runTask; when the budget is exhausted, settle the remaining tasks as rejected without calling them.

Third, per-task timeouts, so a single hung subagent cannot stall a pool slot forever. Promise.race between task.run() and a timeout promise, with the timeout counting as a retryable error, handles this cleanly.

Fourth, structured logging through the name and attempts fields. They are already there for exactly this. Emit a line per settled task and you have an audit trail of which subagents retried and which failed โ€” the data you need to notice an endpoint degrading before it pages you.

None of these change the shape of the library. They hang off the seams the core already exposes, which is the sign that the core was factored correctly. The orchestration feature your frontier model advertises is, underneath, these hundred and fifty lines plus a provider you already pay for. Owning it means you can run subagents against any model, test them without spending a token, and understand exactly what happens when one of them fails โ€” which, at scale, one of them always will.

Further Reading

  • Build a durable agent memory layer in TypeScript โ€” the companion piece on giving subagents memory that outlives the context window.
  • The frontier-model supercycle: GPT-5.5, DeepSeek V4, Gemini, and Opus โ€” why parallel-subagent workflows became a headline feature in 2026.
  • Prediction: a GitHub-native autonomous coding agent ships to production by Q2 2026 โ€” my dated claim on agent orchestration moving from demo to deployment.
  • Companion code on GitHub โ€” the full, tested project from this tutorial.

Signed by Michael Eakins

PGP key fingerprint ends in 08E8 8F19 ยท signed 2026-06-08

Verify โ†’.sig
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

AI agentssubagentsTypeScriptconcurrencystructured outputtutorial
Back to Articles
โ† PreviousSoftBank's 75 Billion Euro France Bet: The Energy Wall Meets Sovereign ComputeNext โ†’Apple's One-Billion-Dollar Dependency: WWDC 2026 and the Gemini Siri Deal

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Engineering and expand your knowledge.

๐Ÿ“„Engineering

Build an LLM-as-Judge Evaluation Harness in TypeScript: Scorers, Rubrics, and a CI Gate

A hands-on TypeScript tutorial for building an LLM evaluation harness: deterministic scorers, an LLM-as-judge rubric scorer, a concurrent runner with retries, and a CI regression gate โ€” provider-agnostic, fully tested.

28 min readRead more
๐Ÿ“„Engineering

Build a Durable Agent Memory Layer in TypeScript: Recall, Summarize, Evict

A hands-on TypeScript tutorial for building a durable agent memory layer: vector recall, hybrid recency-and-salience ranking, scoped retrieval, rolling summarization, and eviction policies โ€” with tests.

28 min readRead more
๐Ÿ“„Tutorial

Parse Streaming JSON From an LLM: A Tolerant Partial-JSON Parser in TypeScript

A hands-on TypeScript tutorial that builds a tolerant partial-JSON parser so an LLM's streamed structured output renders field by field instead of stalling on the closing brace. Tokenizer, auto-closing parser, and 49 offline tests.

21 min readRead more
๐Ÿ“„Engineering

Build a Type-Safe LLM Tool-Calling Layer in TypeScript: Zod Validation and Auto-Repair

A hands-on TypeScript tutorial: validate LLM tool-call arguments with Zod, auto-repair malformed calls in a bounded loop, and dispatch fully-typed args to your handlers โ€” provider-agnostic, tested, and offline.

41 min readRead more