- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Call .compile() on a LangGraph.js StateGraph and follow the class chain. CompiledStateGraph extends CompiledGraph, and CompiledGraph extends Pregel. That is the runtime that executes your agent, and the LangGraph docs say plainly where the name comes from: Google's Pregel, a system for parallel graph computation.
The paper is Pregel: A System for Large-Scale Graph Processing by Malewicz and six co-authors at Google, published at SIGMOD 2010, pages 135–146. It was built for huge graphs spread across a cluster. Your agent has four nodes and runs in one Node.js process. The execution model is the same, and once you see it, LangGraph behaviours that look like quirks stop being surprising: why two parallel nodes cannot see each other's writes, why a join node sometimes runs twice, and why a missing reducer throws instead of picking a winner.
What the 2010 paper specified
A Pregel computation is a sequence of iterations called supersteps. In each superstep the framework calls a user function on every active vertex, conceptually in parallel. A vertex can read the messages sent to it in superstep S − 1 and send messages that will be received in superstep S + 1. Nothing sent in the current superstep is visible until the next one. That gap is the barrier.
Two more ideas from the paper matter here. A vertex deactivates itself by voting to halt, and the program ends when every vertex has halted. Combiners merge several messages bound for one vertex into one, and the paper says to enable them only for commutative and associative operations.
If the name rings a bell: Königsberg was set on both sides of the Pregel River, and Euler's 1736 proof about its seven bridges is considered the first theorem of graph theory.
The LangGraph docs map this onto three phases per super-step:
- Plan: pick the actors to run. On the first step, those subscribed to the input channels; afterwards, those subscribed to channels updated in the previous step.
- Execution: run every selected actor in parallel. Channel updates stay invisible to actors until the next step.
- Update: write the values the actors produced into the channels.
The loop repeats until no actor is selected or the step limit is reached. "No actor selected" is LangGraph's version of every vertex voting to halt. The Graph API docs borrow the paper's vocabulary: nodes start inactive, become active when a message arrives on an incoming edge, and vote to halt when there is nothing left for them.

Every node in a super-step runs in parallel; nothing it writes is visible until all of them reach the barrier.
Channels: where the barrier lives
In LangGraph's version, vertices are nodes and messages go through channels. Every state key becomes a channel, and the channel class decides what happens when writes arrive at the barrier. The getChannel helper in annotation.ts returns a BinaryOperatorAggregate when you give the key a reducer, and a LastValue when you do not.
LastValue is documented as "Stores the last value received, can receive at most one value per step." Its update() takes the array of every write to that key from the whole step. Zero writes, nothing changes. One write, it becomes the value. Two or more, it throws InvalidUpdateError with the code INVALID_CONCURRENT_GRAPH_UPDATE. It does not guess which node should win, because inside one super-step there is no "later" write to prefer.
BinaryOperatorAggregate folds each incoming value into the current one with your reducer: this.value = this.operator(this.value, incoming). This is the paper's combiner. The paper demanded commutative and associative operators so the merge order would not matter. LangGraph relaxes that by sorting each step's writes by task path before applying them, so the order is deterministic and does not depend on which node finished first. An order-independent reducer is still the safer habit.
Topic is a configurable pub/sub channel that collects many values. With accumulate: false it empties at every step; with unique: true it drops values it has already seen.
There is a fourth kind you never declare. Edges are channels too. When StateGraph attaches a node, it creates a channel named branch:to:<node> backed by an EphemeralValue, and makes that the node's trigger. An edge from plan to a is a write of null into branch:to:a that happens as part of plan's output. A node wakes when something writes to its branch:to: channel.
Version counters decide who runs next
The plan phase is a comparison of two maps in the checkpoint, implemented in algo.ts. channel_versions holds the current version of every channel. versions_seen[node] holds the version of each trigger channel that the node saw the last time it ran.
At the barrier, _applyWrites groups every task's writes by channel, calls channel.update(values) once per channel, and stamps each updated channel with a new version. Channels nobody wrote to get update([]), which is how an EphemeralValue edge channel clears itself after one step. It also records, for each node that just ran, the versions of the channels that triggered it.
In the next plan phase, a node gets a task when one of its trigger channels has a version newer than the one in its versions_seen entry. One more detail sets up the barrier: the task's input is computed while the task is being prepared, via _procInput(proc, channels, ...), before any node in that step runs. Each task collects its writes in its own writes array, and nothing reaches the channels until _applyWrites. Every node in a step reads the same state.

Channels hold the state, reducers merge the writes, and version counters decide which node wakes up next.
Watch the barrier happen
This graph fans out from plan to a and b, then joins. a sleeps 50 ms so it finishes last. Each node logs the step number LangGraph puts in its config metadata (langgraph_step and langgraph_node are set per task in algo.ts) and the draft value it sees. The script uses the API of @langchain/langgraph 1.6.0, the current release.
import { Annotation, StateGraph, START, END, } from "@langchain/langgraph"; import { Pregel } from "@langchain/langgraph/pregel"; import type { RunnableConfig } from "@langchain/core/runnables"; const State = Annotation.Root({ draft: Annotation<string>, notes: Annotation<string[]>({ reducer: (left, right) => left.concat(right), default: () => [], }), }); type S = typeof State.State; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function log(config: RunnableConfig, msg: string) { const m = config.metadata ?? {}; const step = m.langgraph_step; const node = m.langgraph_node; console.log(`step=${step} node=${node} ${msg}`); } draft has no reducer, notes concatenates. The four nodes below each log what they see: a writes draft after its sleep, b only appends a note.
async function plan(state: S, config: RunnableConfig) { log(config, `draft=${state.draft}`); return { draft: "v1 from plan", notes: ["plan"] }; } async function writerA(state: S, config: RunnableConfig) { await sleep(50); log(config, `draft=${state.draft}`); return { draft: "v2 from a", notes: ["a"] }; } async function writerB(state: S, config: RunnableConfig) { log(config, `draft=${state.draft}`); return { notes: ["b"] }; } async function join(state: S, config: RunnableConfig) { log(config, `draft=${state.draft}`); log(config, `notes=${state.notes.join(",")}`); return {}; } The last block wires the edges and prints the class behind each compiled channel before it invokes the graph.
const graph = new StateGraph(State) .addNode("plan", plan) .addNode("a", writerA) .addNode("b", writerB) .addNode("join", join) .addEdge(START, "plan") .addEdge("plan", "a") .addEdge("plan", "b") .addEdge("a", "join") .addEdge("b", "join") .addEdge("join", END) .compile(); console.log("is Pregel:", graph instanceof Pregel); const chans = graph.channels as Record< string, { lc_graph_name: string } >; for (const [name, ch] of Object.entries(chans)) { console.log(name, ch.lc_graph_name); } await graph.invoke({ draft: "v0 from input" }); Paste the three blocks into one file, barrier.ts, in an ESM project with @langchain/langgraph and @langchain/core installed, and run it with npx tsx barrier.ts.
I haven't pasted a captured run here. From the source, each line should show the following. The channel dump lists draft as LastValue, notes as BinaryOperatorAggregate, and one branch:to: channel per node. The a and b lines carry the same step number, one above plan's, and both report draft=v1 from plan. b cannot see "v2 from a" because that write sits in a's private writes array until the barrier. The 50 ms sleep does not change that, and it does not change the order of notes either, because writes are sorted by task path, not completion time.
join runs exactly once, one step later, and sees draft=v2 from a plus all three notes. Both a and b wrote to branch:to:join in the same step. That is one channel, updated once at the barrier, with one version bump, so join gets one task.
Now break it. Make writerB also return draft: "v2 from b". The draft channel receives two values in one step and LastValue.update() throws InvalidUpdateError. Give draft a reducer and the error goes away, because the key is now a BinaryOperatorAggregate.
Behaviours that make sense once you see the barrier
A join runs twice when branches have different lengths. Put a b2 between b and join. Now a writes branch:to:join in one step and b2 writes it a step later. Two version bumps, two join runs. That is the model working as specified. The fix in the source is addNode(..., { defer: true }), which swaps the trigger channel for a LastValueAfterFinish. In _applyWrites, finish() is only called on channels once nothing would trigger another step, so the deferred node waits until the rest of the graph has gone quiet.
A failed branch takes the whole step with it. The use-graph-api guide says that if any parallel branch raises, none of the updates are applied and "the entire superstep is transactional." The barrier is the commit point. The checkpointer docs add that LangGraph creates a checkpoint at each super-step boundary and stores the pending writes of the nodes that did succeed, so a resume does not re-run them.
Step limits count barriers, not nodes. The recursion limit defaults to 25 and caps the number of super-steps the graph can execute. Ten nodes fanned out in parallel cost one step; a two-node loop costs two per lap.
When a LangGraph.js agent surprises you, stop reading the diagram as a flowchart and draw it as a table: one row per super-step, the nodes that ran in that row, the channels they wrote, and which versions changed at the barrier. If two nodes in the same row write a key with no reducer, you have found your InvalidUpdateError. To read the runtime itself, open algo.ts first. The docs also show how to build a Pregel instance by hand with NodeBuilder and raw channels, if you want to see the model with StateGraph taken away.
If this was useful
Fan-out, joins, reducers and step limits are where most LangGraph.js agents get stuck, and they all come back to the super-step model in this post. AI That Plans is the book in my series that covers LangGraph.js in TypeScript: state, memory, multi-step and multi-agent graphs, and how to keep them predictable when they run in parallel.
It is book 4 of AI in TypeScript, which runs from your first LLM call to agents in production: see all five books here.




