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.
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.
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.
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()
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]throwsInvalidUpdateError. Separate keys, or a reducer. There is no third option. - Async means async all the way down. One blocking call in an
asyncnode 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.