Parallel Nodes and Fan-Out in LangGraph
Running nodes one after another is the default in LangGraph, but a lot of real workflows do not need that. LangGraph parallel execution lets you fan out to multiple nodes from a single point in the graph, run them concurrently, and merge the results back into shared state. If you are building an agent that calls three APIs, scores five documents, or runs the same prompt over a list of inputs, understanding how to do this correctly (and how state merging works) is the difference between a fast graph and a slow one that just looks parallel on paper.
This article covers static fan-out with multiple edges, dynamic fan-out with the Send API, how reducers merge concurrent writes into state, common failure modes, and how to debug execution order when things do not run the way you expect.
Why LangGraph parallel execution is not automatic
LangGraph compiles your graph into a directed graph of nodes and edges, then executes it using a superstep model borrowed from Pregel-style graph processing. Each superstep, every node whose incoming edges are satisfied runs. If two nodes both become "ready" in the same superstep, they run concurrently, in parallel, within the same step. That is the whole mechanism: parallelism in LangGraph is not something you turn on with a flag, it emerges from the structure of your graph.
That means the default sequential-looking graph:
graph.add_edge("start", "node_a")
graph.add_edge("node_a", "node_b")
graph.add_edge("node_b", "node_c")runs strictly in order because each node depends on the previous one finishing. But this structure:
graph.add_edge("start", "node_a")
graph.add_edge("start", "node_b")
graph.add_edge("start", "node_c")
graph.add_edge("node_a", "join")
graph.add_edge("node_b", "join")
graph.add_edge("node_c", "join")fans out from start to three independent nodes, and they all run in the same superstep because each has its only dependency (start) satisfied at the same time. join will not run until all three finish, because it has three incoming edges.
Static fan-out with multiple edges
The simplest form of parallelism is adding more than one outgoing edge from a node. Here is a minimal working example using StateGraph.
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
query: str
results: Annotated[list[str], operator.add]
def search_web(state: State) -> dict:
return {"results": [f"web result for {state['query']}"]}
def search_docs(state: State) -> dict:
return {"results": [f"docs result for {state['query']}"]}
def search_code(state: State) -> dict:
return {"results": [f"code result for {state['query']}"]}
def combine(state: State) -> dict:
return {"results": [f"combined {len(state['results'])} results"]}
builder = StateGraph(State)
builder.add_node("search_web", search_web)
builder.add_node("search_docs", search_docs)
builder.add_node("search_code", search_code)
builder.add_node("combine", combine)
builder.add_edge(START, "search_web")
builder.add_edge(START, "search_docs")
builder.add_edge(START, "search_code")
builder.add_edge("search_web", "combine")
builder.add_edge("search_docs", "combine")
builder.add_edge("search_code", "combine")
builder.add_edge("combine", END)
graph = builder.compile()
result = graph.invoke({"query": "langgraph parallel", "results": []})
print(result["results"])Three search nodes run concurrently off START, then combine waits for all three before running. Notice the results field uses Annotated[list[str], operator.add]. That annotation is a reducer, and it is not optional here. Without it, three nodes writing to the same key in the same superstep would conflict, because LangGraph does not know how to merge three separate {"results": [...]} writes into one value.
Reducers: how concurrent writes merge into state
This is the part people skip and then get confused by later. When multiple nodes write to the same state key during the same superstep, LangGraph needs a merge strategy. By default, a plain field (no Annotated reducer) uses "last write wins," which is fine for sequential graphs but produces a race condition in a fan-out, because you cannot rely on which parallel branch's write lands last.
The fix is to declare a reducer on any field that multiple parallel branches write to:
from typing import TypedDict, Annotated
import operator
class State(TypedDict):
# last-write-wins, fine for single-writer fields
status: str
# accumulates across parallel writes
logs: Annotated[list[str], operator.add]
# custom merge function
scores: Annotated[dict[str, float], lambda a, b: {**a, **b}]operator.add works for lists (concatenation) and numbers (sum). For dicts, write your own merge function as shown above. If you find yourself fanning out to compute independent pieces of a result, model the state as a list or dict from the start, not a scalar you overwrite, or you will get flaky results that depend on scheduling.
One more subtlety: reducers only apply within a single superstep's batch of writes. Sequential updates to the same field across different supersteps still just accumulate normally through the reducer, so operator.add on a list means "this list grows every time any node touches it," which is usually what you want for something like a running log, but can silently duplicate entries if a node retries.
Dynamic fan-out with the Send API
Static edges work when you know the branch count at graph-build time. But a lot of real fan-out is data-dependent: you have a list of documents to summarize, a list of tickets to triage, a list of URLs to scrape, and the number of parallel tasks depends on runtime input, not the graph definition. That is what Send is for.
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
class State(TypedDict):
documents: list[str]
summaries: Annotated[list[str], operator.add]
class SummarizeState(TypedDict):
document: str
def summarize_one(state: SummarizeState) -> dict:
doc = state["document"]
return {"summaries": [f"summary of: {doc[:30]}"]}
def route_to_summarizers(state: State):
return [Send("summarize_one", {"document": d}) for d in state["documents"]]
builder = StateGraph(State)
builder.add_node("summarize_one", summarize_one)
builder.add_conditional_edges(START, route_to_summarizers, ["summarize_one"])
builder.add_edge("summarize_one", END)
graph = builder.compile()
result = graph.invoke({
"documents": ["doc one text", "doc two text", "doc three text"],
"summaries": [],
})
print(result["summaries"])route_to_summarizers runs once and returns a list of Send objects, one per document. LangGraph spins up one invocation of summarize_one per Send, each with its own isolated input state ({"document": d}), and runs them all in the same superstep. Their outputs merge back into the parent graph's summaries field using the operator.add reducer, same as the static case.
This is the LangGraph equivalent of map() over a list, except each mapped call is a full graph node with its own state, retries, and streaming behavior. It is the right tool any time the fan-out width is a runtime value: batch scoring, per-item enrichment, parallel tool calls where the tool list itself came from a prior LLM call.
Common mistakes and how to spot them
Missing reducers on fan-out targets. If you fan out to N nodes that all write to the same plain field, you get a InvalidUpdateError at runtime, or silent overwrites if you are on an older version. Any field touched by more than one node in a superstep needs an Annotated reducer.
Fan-out that is not actually independent. If your parallel nodes share a mutable resource, like the same in-memory cache, HTTP session, or file handle, running them concurrently introduces real race conditions outside of LangGraph's control. LangGraph's scheduling parallelism does not make your node functions thread-safe by itself, since nodes can execute in separate threads or async tasks depending on your runtime. Keep node functions pure with respect to shared external state, or add your own locking.
Expecting ordered output from parallel branches. Do not assume search_web finishes before search_docs just because you added the edge first. Concurrent means concurrent. If you need an ordering guarantee, either make the dependency explicit with a sequential edge, or sort the merged results by a field you attach in each node's output (a source label, a timestamp, an index).
Blowing up superstep width. Send with a document list of 500 items creates 500 concurrent node executions in one superstep. That is fine if your compiled graph runs on infrastructure that can handle it, but it will hammer rate limits on any LLM or API call inside those nodes. Batch the input yourself (chunks of 10-20) and fan out over chunks instead of individual items when the list is large, or add a semaphore inside the node function to cap actual concurrent API calls.
Debugging non-deterministic node order. If you need to see the actual execution order for a run, stream events instead of only calling invoke:
for event in graph.stream(
{"query": "langgraph parallel", "results": []},
stream_mode="updates",
):
print(event)stream_mode="updates" yields one event per node completion, in the order nodes actually finish, which is the fastest way to confirm your fan-out is really running concurrently and not silently serializing (a common cause: an unintended edge you forgot you added, which turns a parallel branch into a sequential dependency).
Parallel fan-out plus fan-in for a real workflow
Putting it together, here is a pattern that comes up constantly: fan out to independent tool calls, then run a single node that only proceeds once everything has reported back.
from typing import TypedDict, Annotated
import operator
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
ticket_id: str
checks: Annotated[list[dict], operator.add]
verdict: str
def check_priority(state: State) -> dict:
return {"checks": [{"name": "priority", "passed": True}]}
def check_owner(state: State) -> dict:
return {"checks": [{"name": "owner", "passed": True}]}
def check_sla(state: State) -> dict:
return {"checks": [{"name": "sla", "passed": False}]}
def decide(state: State) -> dict:
all_passed = all(c["passed"] for c in state["checks"])
return {"verdict": "approved" if all_passed else "needs_review"}
builder = StateGraph(State)
for name, fn in [
("check_priority", check_priority),
("check_owner", check_owner),
("check_sla", check_sla),
]:
builder.add_node(name, fn)
builder.add_edge(START, name)
builder.add_edge(name, "decide")
builder.add_node("decide", decide)
builder.add_edge("decide", END)
graph = builder.compile()
result = graph.invoke({"ticket_id": "T-1", "checks": [], "verdict": ""})
print(result["verdict"])decide has three incoming edges, so LangGraph waits for check_priority, check_owner, and check_sla to all complete before running it, regardless of how long each one takes. That waiting behavior is automatic, it comes from the edge structure, not from any explicit join or barrier call you have to write.
FAQ
Does LangGraph run parallel nodes in separate threads or as async tasks? It depends on how you invoke the graph and whether your node functions are sync or async. Async node functions run as concurrent asyncio tasks under ainvoke/astream. Sync node functions in a parallel superstep are typically run via a thread pool executor. Either way, do not write to shared mutable Python objects from multiple nodes and expect that to be safe just because LangGraph scheduled them "in parallel" for you.
How many nodes can run in parallel in one superstep? There is no fixed cap in the graph definition itself, it is bounded by whatever compute resources and rate limits your node functions hit at runtime. With Send, the fan-out width equals the length of the list you return, so control it at the call site (chunking, capping list length) rather than expecting LangGraph to throttle it for you.
Why did my parallel branch overwrite another branch's output instead of merging? You almost certainly have a state field without an Annotated reducer that is being written by more than one node in the same superstep. Add operator.add for lists, a custom merge function for dicts, or restructure so each parallel branch writes to its own key instead of sharing one.
Is `Send` required for parallelism, or just for dynamic fan-out? Just for dynamic fan-out. If you know the number of parallel branches ahead of time (a fixed set of tool calls, a fixed set of validation checks), static edges as shown in the first example are simpler and easier to read. Reach for Send specifically when the branch count depends on runtime data, like a list produced by a previous node.
Can a parallel branch itself contain more parallel fan-out? Yes. Each Send target is a full node (or subgraph) and can fan out further internally. Nested fan-out works, but it multiplies concurrency fast, so keep an eye on total in-flight work when you nest it, especially if the leaf nodes make external API calls with rate limits.
Does `stream_mode="updates"` slow down execution compared to `invoke`? No, streaming does not add meaningful overhead, it just changes how results are surfaced to you as the graph runs. Use it during development to confirm real concurrency, and switch to invoke or ainvoke in production if you only need the final state.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.