← all posts

Parallel LangGraph: Subgraphs, Supersteps, and Async Fan-Out

By Pushkar Raj· Sep 5, 2026· 10:00 AM IST· ~16 min read· Generative AI
LangGraphParallel ExecutionSubgraphsAsyncPregel

Most LangGraph apps start as a straight line: node A, then B, then C, each waiting on the last. But three of those nodes might be independent LLM calls, each spending most of its time waiting on the network. Running them one after another is pure wall-clock waste. LangGraph can run them together — and the machinery that makes this safe isn't a thread pool bolted on the side. It's the graph's actual execution model: a runtime called Pregel that advances the whole graph in discrete supersteps, borrowed straight from Google's Pregel paper. Once you see that model, parallelism — plain fan-out, dynamic Send map-reduce, async nodes, and whole subgraphs running side by side — all fall out of the same idea.

01.The problem: sequential graphs waste wall-clock

Say an assistant needs three things about a topic before it can answer: a summary, a set of risks, and a list of citations. Each is its own LLM call. Wired as a chain, the graph does the obvious slow thing — it runs them in series, and your latency is the sum of three calls even though not one of them depends on the others:

# ❌ Three independent calls, billed to the clock one after another
builder.add_edge(START, "summarize")
builder.add_edge("summarize", "find_risks")
builder.add_edge("find_risks", "gather_citations")
builder.add_edge("gather_citations", "answer")
# total latency ≈ t(summarize) + t(find_risks) + t(gather_citations)

LLM calls are I/O-bound: the process sits idle waiting for tokens to come back over the wire. That's the textbook case where concurrency helps — while one call waits, the others can be in flight. If the three run together, total latency collapses toward the slowest single call instead of their sum. The rest of this post is about how to express that in LangGraph without corrupting shared state — and why the framework can do it safely.

02.How LangGraph actually runs: Pregel & supersteps

LangGraph does not "walk" your graph node by node like a call stack. Under the hood it runs a message-passing engine — named Pregel, after Google's 2010 Pregel paper on large-scale graph processing, which itself is an instance of the Bulk Synchronous Parallel (BSP) model. LangChain's own docs put it plainly:

"The underlying graph algorithm uses message passing to execute programs in discrete super-steps. A super-step represents an iteration where active nodes process incoming messages and produce updates. Nodes run in parallel within the same super-step or sequentially across different ones, and the graph execution terminates once all nodes become inactive and no messages remain in transit."

Read that carefully, because the whole post lives inside it. A superstep is one tick of the graph. Within a tick, every node that received a message runs — and if several nodes are active at once, they run concurrently. Crucially, their writes to shared state are not applied the instant a node finishes. They're collected and committed at the end of the superstep, at a synchronization barrier. Only then does the next superstep begin, activating whatever nodes those writes send messages to.

The bulk-synchronous superstep cycle Each superstep has three phases: active nodes compute in parallel, a synchronization barrier waits for all of them, then their state updates are committed together before the next superstep begins. SUPERSTEP N compute (parallel) node A node B node C sync barrier commit updates state channels reducers merge writes superstep N + 1 messages from committed writes activate the next set of nodes — repeat until none are active
Fig 1 — One superstep: active nodes compute in parallel, a barrier waits for all of them, writes are committed together through the state channels, then the next superstep begins.

This is why parallelism in LangGraph is safe by construction. In a normal threaded program, two functions writing the same variable race. In Pregel's BSP model there is no race, because no node ever observes another node's write mid-superstep — everyone reads the state as it was at the start of the tick, and all updates land together at the barrier. The framework only needs one extra thing from you: a rule for how to merge two writes to the same key when they arrive at the same barrier. That rule is a reducer, and we'll hit it in section 04.

03.Fan-out and fan-in with plain edges

The simplest parallelism needs no special API at all. If you add edges from one node to several, those several become active in the same superstep and run concurrently. This is fan-out. Add edges from all of them back into one node, and that node waits for the whole batch before it runs — fan-in. Here are our three independent calls, done right:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    topic: str
    summary: str
    risks: str
    citations: str
    answer: str

def summarize(state: State):
    return {"summary": llm.invoke(f"Summarize {state['topic']}").content}

def find_risks(state: State):
    return {"risks": llm.invoke(f"List risks of {state['topic']}").content}

def gather_citations(state: State):
    return {"citations": llm.invoke(f"Cite sources on {state['topic']}").content}

def answer(state: State):
    return {"answer": f"{state['summary']}\n\nRisks: {state['risks']}\n\nSources: {state['citations']}"}

builder = StateGraph(State)
builder.add_node(summarize)
builder.add_node(find_risks)
builder.add_node(gather_citations)
builder.add_node(answer)

# fan-out: all three activate in the SAME superstep
builder.add_edge(START, "summarize")
builder.add_edge(START, "find_risks")
builder.add_edge(START, "gather_citations")
# fan-in: 'answer' waits for all three, runs in the NEXT superstep
builder.add_edge("summarize", "answer")
builder.add_edge("find_risks", "answer")
builder.add_edge("gather_citations", "answer")
builder.add_edge("answer", END)

graph = builder.compile()
result = graph.invoke({"topic": "retrieval-augmented generation"})

Superstep 1 runs all three producers concurrently; the barrier collects their writes; superstep 2 runs answer. Notice each producer writes a different key (summary, risks, citations) — so there's nothing to merge and no reducer needed yet. The moment two parallel nodes write the same key, that changes.

Fan-out and fan-in graph shape START fans out to three producer nodes that run in parallel in one superstep; all three fan in to a single answer node that runs in the next superstep. START summarize find_risks gather_citations answer superstep 1: three nodes in parallel → superstep 2: answer
Fig 2 — Edges from START to three nodes fan out into one parallel superstep; edges back into answer fan in and gate the next superstep on all three finishing.

04.Concurrent writes need a reducer

Suppose the parallel branches each append to a shared findings list instead of writing separate keys. Two nodes now hand the barrier two writes for the same channel. By default a state key holds a single value and a second write to it in one superstep is ambiguous — LangGraph raises InvalidUpdateError rather than silently dropping one. The fix is to annotate the key with a reducer: a function that tells the channel how to combine writes. For "collect into a list," that's operator.add:

import operator
from typing_extensions import TypedDict, Annotated

class State(TypedDict):
    topic: str
    # reducer: concurrent writes to `findings` are CONCATENATED, not overwritten
    findings: Annotated[list[str], operator.add]

def branch_a(state: State):
    return {"findings": [f"A: {llm.invoke('angle A on ' + state['topic']).content}"]}

def branch_b(state: State):
    return {"findings": [f"B: {llm.invoke('angle B on ' + state['topic']).content}"]}

# both return a one-item list; the reducer merges them into ["A: ...", "B: ..."]

Without the Annotated[..., operator.add], those two parallel writes collide at the barrier. With it, the reducer merges them deterministically regardless of which branch finished first — order of the merged list follows LangGraph's deterministic ordering, not a thread-scheduling coin flip. Rule of thumb: any state key written by more than one node in the same superstep needs a reducer. This is the single most common bug when people first add parallelism, and the error message is telling you exactly this.

05.Dynamic parallelism: the Send API

Fan-out with static edges works when you know the branches at build time. But often you don't: you have a list of items — subjects, chunks, sub-questions — discovered at runtime, and you want one parallel branch per item. That's the classic map-reduce shape, and LangGraph expresses it with Send. A conditional edge returns a list of Send(node_name, state) objects — one per item — and LangGraph spins up that many parallel invocations of the target node, each with its own slice of state:

from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
from typing_extensions import TypedDict, Annotated
import operator

class OverallState(TypedDict):
    topic: str
    subjects: list[str]
    jokes: Annotated[list[str], operator.add]   # reducer: parallel branches append here

def generate_topics(state: OverallState):
    return {"subjects": ["lions", "elephants", "penguins"]}

# The mapper: one Send per subject → N parallel copies of generate_joke
def continue_to_jokes(state: OverallState):
    return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]

def generate_joke(state: dict):
    # note: this node receives the PER-ITEM state sent to it, not the whole OverallState
    return {"jokes": [llm.invoke(f"Tell a joke about {state['subject']}").content]}

def best_joke(state: OverallState):
    return {"best_selected_joke": pick_best(state["jokes"])}

builder = StateGraph(OverallState)
builder.add_node("generate_topics", generate_topics)
builder.add_node("generate_joke", generate_joke)
builder.add_node("best_joke", best_joke)
builder.add_edge(START, "generate_topics")
builder.add_conditional_edges("generate_topics", continue_to_jokes, ["generate_joke"])
builder.add_edge("generate_joke", "best_joke")
builder.add_edge("best_joke", END)
graph = builder.compile()

Two things make Send different from a normal edge. First, the number of branches is dynamic — three subjects here, three hundred next time, decided at runtime. Second, each branch gets a custom state ({"subject": s}), not the full shared state — so the mapped node is written against just the slice it needs. All those generate_joke invocations run in one superstep; they all append to jokes via the reducer; then best_joke reduces the collected results. That's map (parallel Send) and reduce (reducer + aggregator node) in the graph's native vocabulary.

The Send API map-reduce shape A mapper node emits one Send per runtime item, spawning a dynamic number of parallel copies of a worker node, whose outputs are merged by a reducer and consumed by an aggregator. generate_topics subjects=[…] Send() × N (runtime) generate_joke · lions generate_joke · elephants generate_joke · penguins jokes[] operator.add best_joke map: parallel Sends in one superstep · reduce: reducer merges, aggregator consumes
Fig 3 — Send emits one branch per runtime item (dashed = dynamic), all mapped copies run in a single superstep, and a reducer + aggregator fold the results back together.

06.Make it async

Parallel nodes running concurrently still deliver their biggest win when the nodes are async. Define a node with async def and await the model call, then drive the graph with ainvoke / astream. Now while one branch is awaiting tokens, the event loop actually runs the others — true overlap of I/O on a single thread, no thread pool required:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langchain.chat_models import init_chat_model

model = init_chat_model("gpt-4o")

class State(TypedDict):
    topic: str
    joke: str

# async node — accept `config` and pass it through for correct context propagation
async def call_model(state: State, config):
    resp = await model.ainvoke(
        [{"role": "user", "content": f"Write a joke about {state['topic']}"}],
        config,
    )
    return {"joke": resp.content}

graph = (
    StateGraph(State)
    .add_node(call_model)
    .add_edge(START, "call_model")
    .compile()
)

# drive it on the event loop
result = await graph.ainvoke({"topic": "ice cream"})

# or stream tokens as they arrive
async for chunk in graph.astream({"topic": "ice cream"}, stream_mode="messages"):
    ...

The rule that trips people up: don't mix sync and async carelessly. A blocking llm.invoke() inside an async def node stalls the whole event loop and quietly kills your concurrency — every "parallel" branch ends up waiting behind it. If a node is async, its I/O must be awaited (ainvoke, astream, async DB/HTTP clients). And always thread the config argument through to ainvoke so tracing, callbacks, and cancellation propagate correctly. Combine async with the fan-out or Send patterns above and the parallel branches genuinely overlap rather than merely being scheduled together.

07.Subgraphs as parallel units

Everything so far parallelizes single nodes. But real branches are rarely one call — a "research one source" branch might retrieve, then summarize, then score. LangGraph lets you package that whole pipeline as a subgraph: a graph compiled on its own, then dropped into a parent graph as if it were a single node. The parent doesn't care that the node is internally three steps; it treats it as one unit. And because it's just a node, you can fan out to several subgraphs and they run in parallel.

When the subgraph shares state keys with the parent, you add the compiled subgraph directly as a node — it reads and writes the parent's channels automatically:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END

# --- a subgraph: its own little pipeline, shares the `topic`/`findings` keys ---
class ResearchState(TypedDict):
    topic: str
    draft: str
    findings: Annotated[list[str], operator.add]

def retrieve(state: ResearchState):
    return {"draft": search(state["topic"])}

def summarize(state: ResearchState):
    return {"findings": [llm.invoke(f"Summarize: {state['draft']}").content]}

research = (
    StateGraph(ResearchState)
    .add_node(retrieve).add_node(summarize)
    .add_edge(START, "retrieve").add_edge("retrieve", "summarize")
    .compile()
)

If the subgraph has its own private schema that doesn't line up with the parent's keys, you wrap it in a node that transforms parent state → subgraph input and back:

class ParentState(TypedDict):
    topic: str
    findings: Annotated[list[str], operator.add]

# wrapper: translate parent state into the subgraph's schema, then map the result back
def run_research(state: ParentState):
    out = research.invoke({"topic": state["topic"], "findings": []})
    return {"findings": out["findings"]}

Now fan out to several subgraph-backed nodes exactly like section 03 — each runs its full internal pipeline concurrently within one superstep of the parent:

parent = StateGraph(ParentState)
parent.add_node("web_research", run_web_research)     # each of these is a
parent.add_node("docs_research", run_docs_research)   # compiled subgraph (or a
parent.add_node("news_research", run_news_research)   # wrapper around one)
parent.add_node("synthesize", synthesize)

parent.add_edge(START, "web_research")     # fan-out: three subgraphs,
parent.add_edge(START, "docs_research")    # all active in the SAME
parent.add_edge(START, "news_research")    # parent superstep
parent.add_edge("web_research", "synthesize")
parent.add_edge("docs_research", "synthesize")
parent.add_edge("news_research", "synthesize")
parent.add_edge("synthesize", END)
graph = parent.compile()
Three subgraphs running in parallel inside a parent superstep The parent graph fans out to three subgraph nodes; each subgraph is its own retrieve-then-summarize pipeline, and all three run concurrently in one parent superstep before fanning in to a synthesize node. START subgraph: web_research retrieve summarize subgraph: docs_research retrieve summarize subgraph: news_research retrieve summarize synthesize
Fig 4 — Each parent node is a whole compiled subgraph (retrieve → summarize). Fanning out to three of them runs three full pipelines in parallel within one parent superstep, then fans in to synthesize.

08.Send into subgraphs: dynamic parallel pipelines

The two big ideas compose. When the number of research branches is itself dynamic — one subgraph run per document you retrieved, say — point Send at a node backed by a subgraph. You get a runtime-sized fan-out where each parallel branch is a full multi-step pipeline, not a single call:

from langgraph.types import Send

def fan_out_to_research(state: ParentState):
    # one full research subgraph per source, count decided at runtime
    return [Send("research_one", {"topic": src}) for src in state["sources"]]

def research_one(state: dict):
    # this node IS a subgraph invocation over a single source
    out = research.invoke({"topic": state["topic"], "findings": []})
    return {"findings": out["findings"]}   # merged via the reducer on ParentState

parent.add_conditional_edges("plan", fan_out_to_research, ["research_one"])

This is the pattern behind most production "parallel research" agents: a planner emits N sources, each fans out into an independent research subgraph, all run concurrently in one superstep, and their findings merge through a reducer for a final synthesis pass. Map-reduce, but every mapped unit is a pipeline.

09.Gotchas the superstep model forces on you

  • The barrier gates on the slowest branch. A superstep ends only when every active node in it finishes. Fan out to five branches and one is slow, and the whole step waits for that straggler — your parallel step is only as fast as its slowest member. Don't lump a 200ms call and a 30s call into the same fan-out if you can help it.
  • Shared keys need reducers — always. Any channel written by two+ parallel nodes without an Annotated[..., reducer] throws InvalidUpdateError. Separate keys, or a reducer. There is no third option.
  • Async means async all the way down. One blocking call in an async node serializes the event loop and silently erases your concurrency. Audit that every awaited path uses async clients.
  • One branch erroring fails the superstep. By default an unhandled exception in any parallel branch aborts the step. If a branch is allowed to fail, catch inside the node and return a sentinel into the reduced state rather than letting it throw — otherwise one flaky source sinks the whole fan-out.
  • Don't parallelize dependent work. If B needs A's output, they belong in different supersteps (a plain edge), not the same fan-out. Parallelism is for independent branches; forcing dependent ones concurrent just corrupts state or deadlocks on data that isn't there yet.

10.Takeaway

LangGraph's parallelism isn't a feature layered on top — it's the execution model showing through. The runtime is Pregel; it advances the graph in supersteps; within a superstep, active nodes run concurrently and their writes commit together at a synchronization barrier. Everything practical follows from that one fact: fan out with plain edges for known branches, use Send for dynamic map-reduce, make nodes async so I/O genuinely overlaps, and wrap multi-step branches as subgraphs so a single parallel unit can be a whole pipeline. Give every shared key a reducer, keep dependent work in separate supersteps, and remember the barrier waits for your slowest branch. Do that and a graph that used to add up its latencies starts spending only as long as its slowest independent step — which is the whole point.

Building multi-step agents on LangGraph and fighting latency or tangled state? Parallelism, subgraphs, and the superstep model are exactly the kind of thing I help engineers get right.