- 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
Your research agent fans out. One branch pulls internal docs in a single node. The other searches the web, then reranks the hits, so it takes two nodes. Both branches point at combine, the node that calls the model and writes the answer. You open the trace and combine is there twice. The first call answered without the reranked results. The second call answered again, and you paid for both.
Nothing is broken. The LangGraph.js control-flow docs state the rule in one line: "Separate edges run d once per superstep in which any incoming branch completes." With branches of different lengths, the docs add, d runs more than once. To see why, and to pick the right fix, you need the execution model the graph runs on.
Super-steps: the clock behind every graph
LangGraph.js runs your nodes in rounds, which the docs call super-steps. The Graph API overview says execution "advances in discrete super-steps," that "a node becomes active when it receives input on an incoming edge," and that destinations on outgoing edges "run in parallel in the next super-step."
Read the second quote again: an incoming edge, not all of them. A plain addEdge("a", "combine") compiles to a trigger channel. In the StateGraph source, a single-source edge writes to a channel named branch:to:${end}, and any write to it wakes the target up for the next round. Two plain edges into combine mean two sources that can each wake it, and nothing makes one wait for the other.
When both branches have the same length, both writes land in the same round, and combine runs once. When one branch is a step longer, the writes land in different rounds, and combine wakes up in each.

Two branches, two arrival times, two wake-ups for the node they share.
Build the graph that double-runs
The code uses the StateSchema API from @langchain/langgraph (the current npm release is 1.7.0) plus Zod, in an ESM project with @langchain/core installed for the RunnableConfig type. Install with npm i @langchain/langgraph@1.7.0 @langchain/core zod, plus tsx to run it: npm i -D tsx. No model calls, so it runs without an API key. I wrote it against the docs and haven't run it myself, so pin your version and check the log on yours.
// join.ts (ESM, run with: npx tsx join.ts) import { StateGraph, StateSchema, ReducedValue, START, END, } from "@langchain/langgraph"; import type { RunnableConfig } from "@langchain/core/runnables"; import * as z from "zod"; const State = new StateSchema({ question: z.string(), notes: new ReducedValue( z.array(z.string()).default(() => []), { reducer: (cur, next) => cur.concat(next) }, ), combineRuns: new ReducedValue( z.number().default(() => 0), { reducer: (cur, next) => cur + next }, ), }); type S = typeof State.State; function log(config: RunnableConfig, msg: string) { const m = config.metadata ?? {}; const where = `step=${m.langgraph_step} ${m.langgraph_node}`; console.log(`${where}: ${msg}`); } const work = (name: string) => async (s: S, config: RunnableConfig) => { log(config, `sees [${s.notes.join(", ")}]`); return { notes: [name] }; }; async function combine(s: S, config: RunnableConfig) { log(config, `sees [${s.notes.join(", ")}]`); // A real agent calls the model here. return { combineRuns: 1 }; } notes and combineRuns both have reducers, because parallel nodes write in the same round and a plain key throws InvalidUpdateError on a second write in the same step. combineRuns adds up, so the final state tells you how many times combine fired. The langgraph_step and langgraph_node keys are set on each task's config in algo.ts, which is how each log line gets its round number.
The wiring goes at the end of the same join.ts. fetchDocs is the short branch. search then rerank is the long one.
const graph = new StateGraph(State) .addNode("plan", work("plan")) .addNode("fetchDocs", work("fetchDocs")) .addNode("search", work("search")) .addNode("rerank", work("rerank")) .addNode("combine", combine) .addEdge(START, "plan") .addEdge("plan", "fetchDocs") .addEdge("plan", "search") .addEdge("search", "rerank") .addEdge("fetchDocs", "combine") .addEdge("rerank", "combine") .addEdge("combine", END) .compile(); const out = await graph.invoke({ question: "why is p99 up?" }); console.log("combine ran", out.combineRuns, "time(s)"); What each super-step does
I haven't pasted a captured run. Here is the schedule the documented rules produce, counting rounds from whatever step plan logs as N:
| Round | Runs | Why it woke up |
|---|---|---|
| N | plan | edge from START |
| N+1 | fetchDocs, search | both edges from plan |
| N+2 | rerank, combine | search finished; fetchDocs finished |
| N+3 | combine | rerank finished |
The round that hurts is N+2. combine runs next to rerank, not after it, so its log line shows plan, fetchDocs and search but no rerank. In a real agent that is a full model call on half the evidence. In round N+3, rerank's write wakes combine again, and this time it sees everything. The final log line should read combine ran 2 time(s).
The order of entries inside notes can vary between runs. The docs warn that "updates from a parallel superstep may not be ordered consistently," so don't read meaning into which note comes first.
Fix one: an edge with an array of sources
Replace the two edges into combine with one edge that lists both sources:
// was: .addEdge("fetchDocs", "combine") // .addEdge("rerank", "combine") .addEdge(["fetchDocs", "rerank"], "combine") The control-flow docs put it plainly: d "runs once, after both b_2 and c complete." The array form builds a different channel. In the source, an array edge creates a channel named join:${starts.join("+")}:${end} backed by a NamedBarrierValue, "a channel that waits until all named values are received before making the value available." fetchDocs checks in at N+1, rerank checks in at N+2, and combine runs once at N+3.
The catch is in the same docs page: if one of the listed nodes never runs, the target never runs, and no error is raised. A barrier that waits for rerank never opens on a run where rerank was routed around. The run finishes without an error, and combine never writes an answer.

A barrier opens only when every named source has checked in. If one never comes, it stays shut.
Fix two: a deferred node
Keep the two plain edges and mark the join as deferred:
.addNode("combine", combine, { defer: true }) Per the same docs page, { defer: true } postpones the node until no tasks are pending anywhere in the graph, and with separate edges the deferred node runs once, after every selected branch has completed. Under the hood the StateGraph source swaps the node's trigger channel for a LastValueAfterFinish, which only becomes readable once the rest of the run has gone quiet. The Python reference describes the same option as deferring the node "until the run is about to end."
Note the word selected. A deferred join does not name its sources, so it works when some branches are skipped. Route around search, and combine still runs once, on whatever arrived.
The cost is the word anywhere. A deferred node waits for every pending task in the graph, including ones that never feed it. If an unrelated branch is still writing an audit record or calling a slow tool, combine waits for that too.
Which fix fits which shape
Use the array edge when the set of sources is fixed and every source always runs. A static fan-out to three retrievers that each run on every request is the clean case. You get a join that names its inputs, and anyone reading the graph can see exactly what combine waits for.
Use defer: true when the sources vary per run: a conditional edge that sometimes skips a branch, or a Send fan-out whose width the planner decides at runtime. You can't list sources you don't know in advance, and a barrier on a skipped node is the silent skip from fix one. Keep slow side work out of that graph, because the deferred node waits for it.
One more trap if you mix the two. A LangChain forum report from September 2026 describes a conditional edge pointing at a node that is also the target of a multi-source edge. The conditional write goes to the node's ordinary trigger, not the barrier, so the node fires early and then again when the barrier completes. That report is against the Python package and I haven't checked the JS behaviour. The workaround it gives is a no-op pass-through node between the branch and the join, listed in the barrier's sources.
Whichever fix you pick, lock it in with a test. The combineRuns counter already does the work:
import assert from "node:assert/strict"; const res = await graph.invoke({ question: "why is p99 up?" }); assert.equal(res.combineRuns, 1, "combine must run once"); Run that on every graph change. The day someone adds a third step to one branch, the test fails before the token bill does.
If this was useful
When a join fires, what state it sees, and how many model calls a multi-step agent makes are the kind of LangGraph.js questions AI That Plans is built around: state, memory, multi-step and multi-agent graphs, all in TypeScript. If your agent fans out and your traces have started to look busier than your graph, that is the book for it.
It is book 4 of AI in TypeScript, which runs from your first LLM call to agents in production: all five books here.
