Understanding the execution model explains confusing errors and lets you design graphs that are fast and correct.
A compiled StateGraph runs on LangGraph's Pregel engine, named after a Google system for processing large graphs in rounds. Each state key is stored in a channel, and each node is an actor that reads channels and writes to them.
Execution proceeds in super-steps:
1. Plan: pick every node whose inputs changed in the last step (or that was targeted by an edge, Command or Send). 2. Execute them all in parallel. Writes are buffered, so nodes in the same step don't see each other's updates. 3. Update: apply all buffered writes to channels, through reducers. Save a checkpoint. Repeat until nothing is scheduled.
Consequences:
- Two parallel nodes writing the same key without a reducer raise
InvalidUpdateError("INVALID_CONCURRENT_GRAPH_UPDATE"). - A super-step is transactional: if one parallel node fails, none of that step's writes are applied, though successful siblings' results are kept as pending writes so they aren't redone on retry.
recursion_limitcounts super-steps, not nodes.- Branches of different lengths reach a join at different steps; use
defer=Trueon the join node to make it wait until all other pending work is done.
Checkpoint cost. By default each checkpoint stores full channel values. For long threads with growing message lists, LangGraph 1.2 added DeltaChannel (beta), which stores only the change per step plus periodic snapshots. Switching a persisted channel between delta and normal storage is not backward compatible, so plan migrations.
Other performance tools: cache_policy=CachePolicy(ttl=...) with compile(cache=InMemoryCache()) to skip repeated expensive nodes, max_concurrency to limit parallel tasks, and durability="exit" for short runs that don't need mid-run recovery.
Example
import operator
from typing import Annotated, TypedDict
from langgraph.cache.memory import InMemoryCache
from langgraph.graph import StateGraph, START, END
from langgraph.types import CachePolicy
class State(TypedDict):
q: str
hits: Annotated[list[str], operator.add]
def fast(s: State): return {"hits": ["fast"]}
def slow1(s: State): return {"hits": ["slow1"]}
def slow2(s: State): return {"hits": ["slow2"]}
def join(s: State): return {"hits": [f"joined {len(s['hits'])}"]}
b = StateGraph(State)
b.add_node(fast)
b.add_node(slow1, cache_policy=CachePolicy(ttl=60)) # reuse result for 60 s
b.add_node(slow2)
b.add_node(join, defer=True) # wait for all branches
b.add_edge(START, "fast"); b.add_edge(START, "slow1") # step 1: both in parallel
b.add_edge("slow1", "slow2") # step 2
b.add_edge("fast", "join"); b.add_edge("slow2", "join")
b.add_edge("join", END)
graph = b.compile(cache=InMemoryCache())
print(graph.invoke({"q": "x", "hits": []})["hits"])
# ['fast', 'slow1', 'slow2', 'joined 3'] (join ran once, after both branches)Remember
- Pregel runs nodes in super-steps: plan, execute in parallel, apply writes
- Writes in a step are buffered and merged via reducers; steps are transactional
- recursion_limit counts super-steps; defer=True delays joins
- DeltaChannel (beta), caching and max_concurrency tune performance