This page explains how LangGraph decides which node runs in the next superstep. The Pregel and checkpointers pages cover the scheduler model and checkpoint storage around that decision; this page covers the bookkeeping that turns stored state into the next frontier of work. See 01-anatomy-of-an-invoke.md , 03-your-state-compiles-to-channels.md , and 04-control-flow-is-channels-too.md for the adjacent pieces that feed this decision.
The scheduler does not keep a runtime queue for this decision and it does not walk edges from a live graph to discover work. It reads two persisted maps in the checkpoint, channel_versions[channel_name] and versions_seen[node_name][channel_name], and it treats a node as due when one of its trigger channels is available and the current channel version is newer than the version recorded for that channel in that node’s versions_seen entry.
The checkpoint is the scheduler state
These maps answer one question: did any watched channel move since the node last ran? channel_versions records the current version for each channel, and versions_seen records the last version each node has already consumed.
prepare_next_tasks in libs/langgraph/langgraph/pregel/_algo.py builds the next task set, and _triggers performs the version comparison for each candidate node. apply_writes returns the updated channel set, and prepare_next_tasks uses that set with trigger_to_nodes before comparison starts.
Where the check happens
The loop follows a simple sequence: run, consume, write, bump, compare again. A task runs in the current superstep, apply_writes records that its trigger channels now count as seen, BaseChannel.consume() lets a channel clear or compact state, BaseChannel.update() applies the new writes, and BaseChannel.finish() closes out the frontier when nothing else remains to trigger.
apply_writes in libs/langgraph/langgraph/pregel/_algo.py updates versions_seen first, then it advances channel_versions as channels accept writes. prepare_next_tasks reads the saved checkpoint on the next superstep and asks the same version question again against the new frontier.
Example: two nodes over three supersteps
The tokens in this table are opaque order markers. The scheduler compares them; it does not parse them as timestamps or counters.
| Superstep | Node that runs | channel_versions | versions_seen[A][start] | versions_seen[B][work] |
|---|---|---|---|---|
| 0 | A | start=α, work=∅, done=∅ | α | ∅ |
| 1 | B | start=α, work=β, done=∅ | α | β |
| 2 | none | start=α, work=β, done=γ | α | β |
In this graph, A reacts to start and produces work, then B reacts to work and produces done. Each node only shows the trigger channel it watches. After B runs, no trigger channel advances again, so the frontier goes empty and the run stops.
Why versions exist instead of a dirty flag
Versions make replay and crash recovery deterministic. The next frontier comes back from the checkpoint, so no in-memory queue needs to survive a crash or a restart. A node that runs without its trigger channel advancing does not run again, because versions_seen catches up to the current channel version and the comparison turns false on the next pass.
Failure modes
recursion_limit works as a superstep budget, not as call stack depth. When the loop spends that budget, LangGraph raises GraphRecursionError; the user-facing troubleshooting page for GRAPH_RECURSION_LIMIT
covers the error message and the usual response.
The other stop condition is quiescence. When the frontier goes empty, prepare_next_tasks returns nothing, tick() marks the run done, and END in libs/langgraph/langgraph/constants.py names that terminal path. END does not introduce a separate scheduler rule; it just names the stop that already happens when nothing remains due.
Version flow
The same two maps drive every pass through the loop: planning compares them, execution produces writes, and apply_writes moves both maps forward for the next comparison. See 05-why-checkpoints-look-like-that.md
and 06-replay-resume-and-idempotency.md
for the checkpoint shape and replay contract behind that flow.
flowchart LR CV[channel versions] --> Plan[prepare next tasks] VS[versions seen] --> Plan Plan --> Run[execute due nodes] Run --> Writes[writes] Writes --> Apply[apply writes] Apply --> Bump[channel versions advance] Apply --> Seen[versions seen catch up] Bump --> Plan Seen --> Plan
Where to look in the code
libs/langgraph/langgraph/pregel/_algo.py—prepare_next_tasks,_triggers,apply_writeslibs/langgraph/langgraph/pregel/_loop.py—tick,after_tick,_put_checkpointlibs/checkpoint/langgraph/checkpoint/base/__init__.py—Checkpoint,channel_versions,versions_seen,BaseCheckpointSaver.get_next_versionlibs/langgraph/langgraph/channels/base.py—consume,update,finishlibs/langgraph/langgraph/pregel/_checkpoint.py—create_checkpoint,channels_from_checkpoint