Skip to content

Parallelism and concurrency

<Parallel> says "these children don't depend on each other." The run's concurrency limit says how many ready steps may execute at once. They're separate controls: one is about dependency, the other about an execution limit.

The tree already says which is which:

  • A nested child is a dependency. It renders, and runs, after its parent finishes.
  • Siblings in <Parallel> are independent. They're eligible to run together, as soon as whatever they're nested in finishes.
import { openai } from "june/sdk/openai";
import { Agent, Parallel, Step } from "june";

export default function Review({ diff }: { diff: string }) {
  return (
    <Agent model={openai("gpt-5-mini")}>
      <Step id="summary" prompt={`Summarize this diff:\n${diff}`}>
        {(summary) => (
          <Parallel>
            <Step id="risks" prompt={`List the risks:\n${summary}`} />
            <Step id="tests" prompt={`Suggest tests:\n${summary}`} />
          </Parallel>
        )}
      </Step>
    </Agent>
  );
}

risks and tests both depend on summary and not on each other, so they start together once it finishes. The Code Review Agent is this shape at full size: one plan, then specialists in parallel.

<Parallel>

Wrap work that doesn't depend on its siblings in <Parallel>. Each child can then start, finish and render what depends on it without waiting for the others. It doesn't raise the concurrency limit; the limit still decides how many of their steps execute at a time.

How it works

<Parallel> wraps each child in its own Suspense boundary and renders a parallel node on the trace (with an optional id prop to name it). Each child commits, to the trace and to onUpdate, as soon as its own work is ready, and work that depends on it can go on without waiting for its siblings.

Without <Parallel>, siblings share the closest Suspense boundary. React may still render ahead into a suspended sibling and start its steps, but nothing in a shared boundary commits until all of it is ready: the trace doesn't show it, and effects in it, such as a retry, don't run. Don't rely on React rendering ahead; mark independent work with <Parallel>.

<Step> and <Catch> each put their own children in a boundary, so what a step renders never holds back that step's siblings. The difference shows with components that suspend themselves: a component that calls useStep, or a <Step> with output or tools, which suspends briefly while the AI SDK resolves its schemas.

Fan-out

A .map() over data is dynamic fan-out. Give each step an id derived from its item:

// triage.tsx
import { openai } from "june/sdk/openai";
import type { LanguageModel } from "ai";
import { Agent, Parallel, Step } from "june";

export interface Issue {
  number: number;
  title: string;
}

export default function Triage({
  repo,
  issues,
  model = openai("gpt-5-mini"),
}: {
  repo: string;
  issues: Issue[];
  model?: LanguageModel;
}) {
  return (
    <Agent
      model={model}
      instructions="Label the issue as one of: bug, feature, question, docs. Answer with the label only."
    >
      <Parallel>
        {issues.map((issue) => (
          <Step
            key={issue.number}
            id={`triage-${issue.number}`}
            prompt={`${repo}#${issue.number}: ${issue.title}`}
          />
        ))}
      </Parallel>
    </Agent>
  );
}
june run triage.tsx --input-file ./issues.json
  • key is React's; id is June's. Derive both from the item, not its position. An id made from an array index names whatever item is at that position, so after the list changes a snapshot's entries no longer line up with the items and their steps run again. Two lists that both use ids like item-${i} collide and fail the run. Step identity has the rules.
  • Long lists are fine. Rendering 500 steps renders 500 nodes, but only the concurrency limit executes at once; the rest wait as queued.

Concurrency

A run executes at most 8 steps at once. Set concurrency on run() or start() to change it, or pass Infinity for no limit. Anything else, such as 0 or 2.5, fails with june: concurrency must be a positive integer or Infinity. june run always uses the default.

import { run } from "june";
import type { ReactNode } from "react";

declare const workflow: ReactNode;

await run(workflow, { concurrency: 4 });

What the limit counts

  • Steps, not model calls. A <Step>'s whole tool loop holds one slot, however many model and tool calls it makes, and so does a useStep that makes several calls itself. june/copy's <Write> and june/train's <FineTune> and <Evaluate> are steps too.
  • Executing steps, not rendered ones. Rendering isn't throttled. A step that's ready past the limit is queued on the trace, and queued steps start in the order they became ready as slots free up. startedAt and durationMs start when a step first gets a slot, not when it's queued, though time it spends waiting on an approval still counts.
  • Not waiting on people. A step waiting on a tool approval gives up its slot until it's answered, then queues for one again.
  • Not reused results. A step resumed from a snapshot takes no slot, and steps that share an id take one between them.

Each run has its own limit; two runs in one process don't share slots. The limit isn't a rate limiter: it doesn't know about tokens or a provider's rate limits. Lower it, or use the agent's maxRetries to retry rate-limited calls.

Fan-in

Fan-in means: wait for a group of independent steps to settle, then render the work that needs all of their results.

A result flows only to what its step renders, so a step that needs several results has to be rendered where all of them are in scope. Nest the steps it reads, and render them once more inside <Parallel> so they start together. Steps with the same id and definition share one execution, so each runs once:

// research.tsx
import type { ReactNode } from "react";
import { openai } from "june/sdk/openai";
import type { LanguageModel } from "ai";
import { Agent, Parallel, Step } from "june";

export function Research({
  topic,
  children,
}: {
  topic: string;
  children?: (brief: string) => ReactNode;
}) {
  return (
    <Step id={`sources-${topic}`} prompt={`List the key sources on ${topic}`}>
      {(sources) => (
        <Step
          id={`brief-${topic}`}
          prompt={`Write a one-paragraph brief on ${topic} from:\n${sources}`}
        >
          {children}
        </Step>
      )}
    </Step>
  );
}

export default function Compare({
  a,
  b,
  model = openai("gpt-5-mini"),
}: {
  a: string;
  b: string;
  model?: LanguageModel;
}) {
  return (
    <Agent model={model}>
      <Parallel>
        <Research topic={a} />
        <Research topic={b} />
      </Parallel>
      <Research topic={a}>
        {(first) => (
          <Research topic={b}>
            {(second) => (
              <Step
                id="comparison"
                prompt={`Compare ${a} and ${b}:\n${first}\n\n${second}`}
              />
            )}
          </Research>
        )}
      </Research>
    </Agent>
  );
}

The first <Parallel> starts both topics at once. The chain below it renders the same steps again: it waits for each step in turn, all of them already running, and comparison runs once both briefs are in. The model is called five times, not nine. The shared steps appear on the trace once per place they're rendered, with the same state.

For a list of any length, the chain can be recursive:

import type { ReactNode } from "react";
import { Step } from "june";

interface Issue {
  number: number;
  title: string;
}

function Label({
  issue,
  children,
}: {
  issue: Issue;
  children?: (label: string) => ReactNode;
}) {
  return (
    <Step id={`triage-${issue.number}`} prompt={`Label: ${issue.title}`}>
      {children}
    </Step>
  );
}

export function Labels({
  issues,
  labels = [],
  children,
}: {
  issues: Issue[];
  labels?: string[];
  children: (labels: string[]) => ReactNode;
}) {
  const [first, ...rest] = issues;
  if (!first) return children(labels);
  return (
    <Label issue={first}>
      {(label) => (
        <Labels issues={rest} labels={[...labels, label]}>
          {children}
        </Labels>
      )}
    </Label>
  );
}

Render <Label> for every issue inside <Parallel>, and <Labels issues={issues}> beside it with a function child that uses every label. When the caller only needs the results, skip fan-in: run() resolves with every result in steps.

Next