Subgraphs and Parallelism: Fanning Out and Nesting

The Send API for running many workers at once and merging their results, subgraphs that compose a whole graph as a single node, and the reducer-channel gotcha where a subgraph double-counts state at the boundary — with the fix, built into a parallel on-call diagnostic system.

A supervisor routes to one specialist at a time — a serial flow, one agent after another. But some work is independent: when an alert fires, you want to check the logs and the metrics and the recent deploys at once, not one after the other, because they don’t depend on each other and doing them in sequence just wastes time. And as systems grow, you want to package a whole subsystem as one reusable node. This chapter is the two mechanisms for that — Send for running work in parallel, and subgraphs for nesting — plus a state-sharing gotcha that only shows up when you run it, and its fix. We build a parallel on-call diagnostic system as we go. Run against langgraph 1.2.9.

The Send API: one worker per item

To run work in parallel, a node returns a list of Send objects instead of a state update. Each Send("node_name", payload) spawns an independent invocation of that node with its own payload, and they all run concurrently:

from langgraph.types import Send

def dispatch(state):
    return [Send("diagnose", {"check": c, "alert": state["alert"]})
            for c in ["logs", "metrics", "recent_deploys"]]

This is map-reduce. dispatch is the map that fans out one Send per check, each carrying its own payload ({"check": "logs", ...}) so the worker knows what to do. diagnose is the worker that runs on each. And the reduce is handled by a reducer on the channel the workers write to. That reducer is not optional: several diagnose invocations finish concurrently and all write to findings, so the channel must know how to merge concurrent writes:

from typing import Annotated, TypedDict
import operator

class State(TypedDict):
    alert: str
    findings: Annotated[list[str], operator.add]   # merges the parallel writes
    root_cause: str

Without the operator.add reducer, concurrent writes to findings would conflict — Chapter 6’s overwrite semantics can’t merge two simultaneous writers, so the last-write-wins default would silently drop all but one worker’s result. This is the single most important thing to get right about Send: the target channel needs a reducer, or the parallel results clobber each other. It’s the exact scenario the custom-reducer discussion in Chapter 6 was preparing you for.

The on-call diagnostic system

Put it together. An alert fans out to parallel diagnostic workers; each returns a finding; a synthesis node reads all the findings and correlates a root cause:

from langgraph.graph import StateGraph, START, END

def diagnose(state):                      # runs once per Send, concurrently
    c = state["check"]
    return {"findings": [check_system(c, state["alert"])]}

def synthesize(state):                     # runs after all workers finish
    return {"root_cause": correlate(state["findings"])}

g = StateGraph(State)
g.add_node("diagnose", diagnose)
g.add_node("synthesize", synthesize)
g.add_conditional_edges(START, dispatch, ["diagnose"])   # fan out
g.add_edge("diagnose", "synthesize")                      # implicit join
g.add_edge("synthesize", END)
app = g.compile()

Verified end to end: three diagnostics — logs, metrics, recent_deploys — ran in parallel, their findings merged into one list via the reducer, and synthesize correlated the root cause with the deploy it found. Two things about the wiring earn a name. The fan-out is a conditional edge from START whose function returns the Send list — the special case flagged back in Chapter 7. And the join is automatic: synthesize doesn’t run until every diagnose invocation has completed, because LangGraph knows all the fanned-out workers feed into it. You get the barrier for free: no explicit “wait for all,” no counting, no callbacks. This is the payoff over a serial supervisor loop: where a loop checks three things in three round-trips, Send checks them in one, and the total wait is the slowest check rather than the sum of them.

Subgraphs: a graph as a node

The second mechanism is nesting. A compiled graph is a Runnable, and a node is anything that takes state and returns an update — so a compiled graph can be a node in a bigger graph:

subgraph = sub_builder.compile()          # a complete, self-contained graph

parent = StateGraph(State)
parent.add_node("child", subgraph)         # the whole subgraph, as one node
parent.add_edge("pre", "child")

This is how you encapsulate a subsystem — a whole diagnostic pipeline, a document-processing flow, a specialist agent — and reuse it, or nest a team under a hierarchical supervisor. The parent treats child as a black box: it runs the subgraph and gets its final state back. It’s the same “graph as a node” idea that makes a create_agent usable inside a bigger graph — a subgraph is just that, generalized to any graph you build.

The gotcha: shared reducer channels double-count

Here’s the sharp edge, and it’s exactly the kind this series exists to catch, because it passes review and only surprises you at runtime. When a subgraph shares a channel that has a reducer with its parent, the pre-existing value gets counted twice at the boundary. Verified: a parent node pre writes log=['pre'], then a subgraph node adds ['inner ran'], and the final log is not ['pre', 'inner ran'] — it’s:

['pre', 'pre', 'inner ran']

The 'pre' appears twice. Here’s the mechanism: the subgraph receives the parent’s channel value (['pre']) as its starting state, accumulates internally to ['pre', 'inner ran'], and returns that whole list as its output for the shared channel; the parent’s operator.add reducer then appends the subgraph’s full output to the value the parent already holds, concatenating ['pre'] with ['pre', 'inner ran']. The very reducer that makes parallel fan-out safe is what double-counts across a subgraph boundary.

The fix is to stop sharing the accumulating channel across the boundary. The clean way is to give the subgraph its own state schema and translate at the node — the subgraph works in its own vocabulary, and you map results in and out explicitly:

class SubState(TypedDict):
    findings: Annotated[list[str], operator.add]   # the subgraph's own channel

subgraph = build_sub(SubState).compile()

def call_sub(state: Parent):                      # a node that wraps the subgraph
    result = subgraph.invoke({"findings": []})     # give it a fresh, private state
    return {"log": result["findings"]}             # map its output back deliberately

Verified: with the subgraph owning its state and the wrapper mapping the result back, the parent log is ['pre', 'inner ran'] — no double-count. The rule of thumb: a subgraph should own its accumulating state, not share the parent’s. When results need to cross the boundary, return them as a fresh value the parent merges on purpose, not through a channel both sides append to. (The alternative — keeping shared channels overwrite-only, with no reducer — also works, but the own-schema approach is cleaner and keeps the subgraph reusable in other parents.)

Send or subgraph?

They solve different problems and compose. Send is for breadth — the same work over many items, concurrently. A subgraph is for depth — packaging a multi-step subsystem as one reusable unit. You combine them freely: fan out with Send to invocations of a subgraph, running several complete pipelines in parallel. That’s how a research system runs three independent multi-step researchers at once — Send for the three, a subgraph for the “research one topic” pipeline each runs. Reach for Send when you’re doing one thing to many inputs, and for a subgraph when you’re wrapping many steps into one node.

Final thoughts

Parallelism in LangGraph is Send plus a reducer: fan out one Send per item, let the workers run concurrently, and merge their writes through a reducer the fan-out requires — with the automatic join meaning your total latency is the slowest worker, not the sum. Subgraphs nest a whole graph as a node for reuse and hierarchy, with the crucial caveat that a subgraph should own its accumulating state, or values double-count at the boundary; the fix is a private schema and an explicit map. With handoffs, fan-out, and nesting, you now have every structural move multi-agent systems are built from. Next we get the prebuilt versions of these patterns — and then build the whole thing.

Next: prebuilt shortcuts — langgraph-supervisor and langgraph-swarm, the same systems you just hand-built, in a fraction of the code, and when each is worth it.

Comments