This article is published in English.
How LangGraph Runs a Graph: From graph.invoke() Down to the Pregel Loop
A source-level walkthrough of graph.invoke, stream, SyncPregelLoop, tick, task preparation and run_with_retry, plus a worked parallel example driven by channel versions.
LangGraph feels easy to pick up. add_node(), add_edge(), add_conditional_edges() and graph.invoke() are all simple to grasp, and working graphs come together within hours. After a few projects, though, a question tends to nag: what really happens when you call graph.invoke()? How does the framework pick which node runs, handle conditional edges, update channels and run nodes in parallel? Where do retries, checkpoints, interrupts and streaming fit? Most tutorials cover how to use LangGraph and skip the internals. This walk-through follows the call from the public API down into the Pregel engine, so you can see how tasks are scheduled and state moves, one step at a time.
Why Pregel
A graph is more than a chain of function calls. Several nodes can run at once, some need the outputs of others, state updates must stay consistent, and the framework has to support retries, checkpoints, interrupts and streaming. Pregel handles all that by running the graph in synchronized steps. In each step, every eligible node runs independently. Their outputs are gathered and applied together when the step ends, and only then does the next group of nodes get triggered. That makes parallel execution deterministic and keeps state updates predictable.
The loop repeats until no tasks remain runnable, or until execution is interrupted, fails or reaches the recursion limit.
Step 1: graph.invoke()
Everything begins with this call:
result = graph.invoke(state)
You'd expect that function to run the graph, but it doesn't. graph.invoke() is tiny. It exists as a convenient API that runs the graph to completion and returns the final state, and it hands the actual work to stream():
graph.invoke()
│
▼
stream()
The reason is that LangGraph already has a streaming engine that can yield updates after every Pregel step. Rather than maintain two execution paths, invoke() consumes the stream internally and returns the last value. From here on, the real execution happens inside stream().
Step 2: stream()
If invoke() is the public face, stream() is the orchestration layer. Before any node can run, it prepares the entire runtime:
- Filling in default configuration
- Setting up callback managers and checkpointing
- Wiring streaming handlers
- Building the Runtime object
- Constructing the engine that does the execution
That last item matters most. During this setup, LangGraph builds the two objects that drive the rest of the run:
SyncPregelLoop
PregelRunner
They split execution into two separate jobs:
SyncPregelLoop
│
├── decides WHAT should run
│
PregelRunner
│
└── executes it
Keeping scheduling apart from execution means neither depends on the other.
Step 3: SyncPregelLoop
With initialization done, execution enters the Pregel loop, where each iteration is one Pregel superstep. Every iteration follows the same lifecycle:
Determine runnable nodes
│
Execute those nodes
│
Apply every write together
│
Trigger the next set of nodes
What matters is that writes don't land right away. All nodes in a step see an identical snapshot of state, and the writes are committed in one batch only after the last runnable node completes. That one design choice is why parallel execution is deterministic.
Step 4: tick()
The first function run in each Pregel step is tick(). People often assume it executes graph nodes, and it doesn't. It only prepares the work for the current iteration, with a few bookkeeping tasks:
- Checking whether execution should stop because of the recursion limit, an interrupt or completion
- Restoring pending writes when resuming from a checkpoint
- Emitting debug events
- Calling
prepare_next_tasks()
Then it returns a boolean. True means work remains, and False means execution is finished. At this moment no node has run. tick() has only worked out what could run.
Step 5: prepare_next_tasks()
This function is the scheduler of the whole engine. Given the current graph state, it finds every node eligible to run in this step, and it creates two kinds of tasks.
PUSH tasks are explicit. They come from constructs such as Send, goto and call(), meaning something directly asked another node to run.
PULL tasks are implicit. Nobody requests them. They fire because one of the node's input channels changed during the previous step. For each candidate node, LangGraph asks whether any trigger channel has received a newer version, and if so, the node becomes executable. After finding the runnable nodes, the scheduler passes each one to:
prepare_single_task()
Step 6: prepare_single_task()
So far all that's known is that node X should run. This function turns that abstract decision into something the engine can execute, building a PregelExecutableTask. The code takes one of two paths.
For PUSH, explicitly scheduled tasks are handled by one of these, depending on how they were created:
prepare_push_task_send()
or
prepare_push_task_functional()
The PULL path does considerably more. First, _triggers() confirms the node should run. It doesn't just check whether a channel holds data. It compares channel versions:
current version > last version consumed
Only newer versions trigger execution, so nodes don't keep re-running on unchanged state. After a node is chosen, _proc_input() prepares its input from graph channels, managed values, the scratchpad and cached inputs where possible. If a mapper exists, the input is converted into the schema the node expects, for example:
TypedDict
↓
Pydantic Model
Finally, LangGraph builds the execution metadata, runtime configuration, checkpoint information and retry configuration, and bundles it all into a PregelExecutableTask. The scheduler's work ends there and the task is ready to run.
Step 7: PregelExecutableTask
Treat this as the package handed to the runner. It holds everything needed to run one node: the node runnable, the prepared input, runtime configuration, retry policy, writers, checkpoint namespace, execution metadata and cache information. Nothing inside it decides when execution happens. It's just the contents needed once execution starts.
Step 8: PregelRunner.tick()
This is where execution finally takes place. With only one task ready, such as:
Node A
LangGraph calls the following directly:
run_with_retry()
When several tasks are independent, like these:
Node B
Node C
Node D
they're all submitted at once as Future objects, each one standing for a running node. The runner waits for whichever finishes first, commits completed writes, handles failures, schedules retries when needed, and carries on until every runnable task from the current step is done.
One thing to note: parallelism exists only within a single Pregel step. Sequential dependencies still hold, because the next step can't start until every task in the current one is complete.
Step 9: run_with_retry()
Here is where your node's Python function is first called. All earlier steps were scheduling. This function runs the runnable and deals with the concerns around it. Its duties: call the node, wipe stale writes ahead of any retry, apply the retry policy with exponential backoff, deal with ParentCommand and GraphInterrupt, and log each write the node emits. When execution ends, control returns to the Pregel loop, where after_tick() commits all the writes together before the next step begins.
A worked example with parallel nodes
Take a graph in which two nodes truly run in parallel, so the way channel versions decide that becomes visible:
┌──→ fetch_weather ──┐
START → classify┤ ├──→ respond → END
└──→ fetch_news ─────┘
Here classify branches into fetch_weather and fetch_news, which are independent of each other, and both lead into respond. Neither depends on the other's output, only on that of classify. Here are the channel versions at the start:
channel: "input" version: 1 (last consumed by classify: 0)
channel: "weather_data" version: 0 (last consumed by respond: 0)
channel: "news_data" version: 0 (last consumed by respond: 0)
_triggers()compares each node's trigger channel with what it last consumed.classifywatches"input". The version is1and the last consumed was0, so1 > 0and it counts as newer.fetch_weatherandfetch_newswatch a channel thatclassifyhasn't written to yet, so they don't trigger.- The result is that only
classifybecomes a PULL task in this step.
In step 1, classify runs and writes this output:
{"needs_weather": true, "needs_news": true}
That bumps two downstream channels:
channel: "needs_weather" version: 0 → 1
channel: "needs_news" version: 0 → 1
prepare_next_tasks()scans again.fetch_weatherwatches"needs_weather", where version1 > 0(last consumed), so it triggers.fetch_newswatches"needs_news", where1 > 0also holds, so it triggers.- Both become PULL tasks in the same Pregel step, since both conditions came true from one write.
respondwatches"weather_data"and"news_data", and neither has changed yet, soresponddoesn't trigger.
This is the moment parallelism happens. Two nodes trigger off the writes of the same superstep, so PregelRunner.tick() submits both as Future objects and runs them concurrently.
In step 2, fetch_weather and fetch_news run concurrently and both write:
channel: "weather_data" version: 0 → 1
channel: "news_data" version: 0 → 1
- Although the two nodes start and finish independently, their writes aren't applied until both futures complete. That's the "apply every write together" rule from the loop section.
- Once the batch commits,
prepare_next_tasks()goes over the graph again.respondlistens to"weather_data"and"news_data", and each has moved past the last consumed version (0), so it fires. respondis the only PULL task in this step.
In step 3, respond runs and no channel changes follow. Nothing downstream is triggered by that, which means tick() hands back False and the loop stops.
That makes three Pregel steps in total, yet step 1 scheduled two nodes at once for a single reason: prepare_next_tasks() found two channel versions bumped by one batch of writes. Compare a purely sequential graph, where each step yields one runnable task since only one channel changes at a time. The version-comparison rule is identical in both cases. Parallelism isn't a special case that LangGraph detects. It falls out naturally whenever several channels update within the same superstep.