Node or Function?
When a node earns its overhead, and when you should have written a plain function.
Every chapter in Part VII has described what a graph gives you. This one is the invoice, and it closes the part with the question the part exists to answer.
There is a rule underneath it, and it is short enough to state before the argument:
A node is a boundary. Draw one only where you want something to happen at it.
Four things happen at a node boundary and nowhere else. If a step needs none of them, it is a function that some node calls.
The four things a boundary buys
| At the boundary | What you get | What it costs |
|---|---|---|
| A checkpoint is written | Resume, replay, time travel | A serialize plus a store write, per superstep |
| Execution can pause | Human gate, interrupt | The node must be re-entrant, because resume re-runs it from the top |
| A retry policy applies | Per-node attempts and backoff | The node must be idempotent |
| An event is emitted | A stream update and a named trace span | A name your consumers now handle |
There is a fifth, structural rather than runtime. A node is on the path by construction. compile() can tell you that every route to END passes through it. That is not something a function call inside another node can promise, and it is the reason Atlas's policy check is a node despite containing no model call and no side effect.
Those five are the entire case for a boundary. The test is to name which one you want. If the answer is "it feels like a step," you have described a function.
Checkpoint cost is a dial, but a graph-wide one
Per-superstep writes are the default, not a law. LangGraph exposes a durability setting with three positions: "sync" persists before each step starts (strongest, slowest), "async" persists while the next step runs (a small window where a crash loses the write), and "exit" checkpoints only at the end (fastest, and it gives up mid-run resumption).
The catch is that it is one setting for the whole graph. You cannot make one chatty node cheap without making the pause you actually needed cheap too. And at "exit" there is no mid-run state to resume from at all. So the dial does not rescue an over-noded graph; it just lets you choose which guarantee to lose.
Two failure modes, and they are not symmetric
Too many nodes is the common one. A node per step, unconditional edges between all of them, a diagram that renders as a straight line. The costs compound quietly: write amplification on every superstep, a trace where the signal is buried in spans that only assign a field, and checkpoints that differ from their predecessor by one key.
The cost people miss is that node names are a schema. Position is data, which was the whole point of the graph, so the names are written into every checkpoint. Rename a node and a checkpoint written before the rename refers to a position that no longer exists. Splitting a step into two nodes is a code change; splitting a node is a migration on anything in flight. Fine-grained graphs are expensive to refactor for exactly the reason they were attractive.
Too few nodes is rarer and sharper. One node containing the whole pipeline gets you a trace with a single span called run, a retry that re-runs everything, and no pause point, because you cannot interrupt inside a node. That last one is what actually bites. A coarse node forecloses the human gate you will want in Part XII, and discovering that later means splitting a node, which is the migration above.
The upper bound is where the two failure modes stop being aesthetic:
A node that can pause must contain exactly one side-effecting call.
Resume re-executes the node from the top. Not from the interrupt() line. From the top. Anything that ran before the interrupt runs again, which is why the human gate topology is the shape where this rule earns its keep. That is a correctness constraint, and it is the only hard limit in this chapter. Everything else is cost.
The option most people skip
"Node or function" reads as binary, and it is not. LangGraph's functional API is a third tier: ordinary control flow with if, for, and while, plus checkpointing and interrupts, and no state schema, no reducers, no edges.
import { task, entrypoint, interrupt } from '@langchain/langgraph';
const gather = task('gather', async (ticket: Ticket) => {
// the whole agent loop, as an ordinary bounded `while`
});
const checkPolicy = task('check_policy', async (f: Findings) => { /* ... */ });
const atlas = entrypoint(
{ name: 'atlas', checkpointer },
async (ticket: Ticket) => {
const findings = await gather(ticket);
const decision = await checkPolicy(findings);
if (decision.needsHuman) {
const approval = interrupt(decision.summary); // pauses, persists, resumes
if (!approval.granted) return escalate(decision, approval.reason);
}
return finalize(decision);
},
);Same durability, same pause, none of the graph. What you give up is real: no topology to enumerate or draw, state scoped to each function instead of shared through a schema, and time travel, which is worth understanding as a consequence rather than a gap. A @task updates the checkpoint belonging to its @entrypoint instead of writing a new one, so there is no per-step history to rewind into. That is how the API is built, not a feature waiting to land. If replaying a run from an arbitrary step is something you need, it is an argument for StateGraph that will not expire.
So the actual decision has three tiers, not two:
| Control flow | Survives the process | Can pause | Topology you can enumerate | |
|---|---|---|---|---|
| Plain function | Yours | No | No | No |
@task / @entrypoint | Yours | Yes | Yes | No |
Node in a StateGraph | Data | Yes | Yes | Yes |
The middle row is the one teams skip, and skipping it is how you end up with a twelve-node graph built to obtain resumption. You pay for a state schema, reducers, and an edge list to get a property the middle row hands over for a decorator.
The decision, per step
| If the step… | Then | Because |
|---|---|---|
| Makes a bounded model call and returns fields | Node | You want the retry policy and a named span |
| Purely transforms state: parse, format, map | Function, called by a node | Nothing at the boundary applies to it |
| Performs a side effect | Node, alone | So a retry or a resume repeats exactly one thing |
| Waits for a human | Node, alone | Pause points only exist between nodes |
| Is expensive and deterministic in its inputs | Node, with a cache policy | The boundary is what a cache key can hang off |
| Must be provably unskippable | Node | compile() can verify every path crosses it |
| Assigns one field | Function, merged into its neighbour | You would be checkpointing a rename |
| Loops over items with no pause inside | Function, or a fan-out if you need per-item retry | A for loop does not need an edge |
Retry and cache policies attach to the node, which is the mechanical reason those rows say node:
g.add_node("triage", triage, retry_policy=RetryPolicy(max_attempts=3))
g.add_node("embed_query", embed, cache_policy=CachePolicy(ttl=120))The TypeScript API mirrors this
Same options on addNode, camelCased, as with the rest of the graph API in State, Nodes, Edges. The policies are the transferable idea; the spelling is not.
The row that gets argued about is the pure transform, and the argument is always "I want to see it." Visibility is a tracing concern. You get it from a span, and a span costs a decorator; a node costs a checkpoint, a stream event, and a name in your position schema. Instrument it. Don't checkpoint it.
And should it be a graph at all?
Part VII opened by deferring this. Three questions, all yes-or-no:
- Is there more than one run shape? If every run visits the same nodes in the same order, there is one shape.
- Does anything need to survive the process? A crash, a deploy, a three-day wait for a person.
- Does someone who is not the author need to know what the system can do?
Zero yeses is a function, and Part VI is the chapter for it. One yes is usually the functional API. Two or three is a graph.
The instructive failure is adopting a graph for question 3 alone. Generated legibility is a genuine purchase, since a diagram derived from the system does not rot the way a hand-maintained one does. But if the answers to 1 and 2 are no, what gets generated is a picture of a straight line, and you have bought per-step checkpointing to render it.
And the ceiling is worth naming here rather than in Part X. Graph checkpointing persists your graph's position. It does not make the API call inside a node exactly-once, and it does not survive a node that succeeded remotely and failed locally. When that gap starts costing money, the answer is not a finer-grained graph. For Atlas it does, at issue_credit. It is durable execution, and Part X argues the escalation properly.
Atlas, audited
Running the test against the six nodes:
| Node | Boundary earns it via | Verdict |
|---|---|---|
triage | Retry policy, named span | Node |
gather | The cycle needs an edge; per-iteration stream events | Node |
check_policy | Structural, since every path must cross it | Node |
compose | Retry policy, named span | Node |
escalate | Side effect, alone | Node |
finalize | Side effect, and every path ends here | Node, with a caveat |
The caveat is worth stating plainly, because it is a flaw in this book's own earlier example. finalize does three things: cite, log, send. It never pauses, so the exactly-one-effect rule does not bite. But it carries a retry policy, and a retry re-runs all three: a second send on a transient failure in the logging call. The fix is not a fourth node. It is that each of the three is idempotent, keyed on the ticket. Node count is the wrong lever for a property that belongs to the effects themselves.
What is not in that table is the more useful half. Entity extraction is a function inside triage, not a node beside it. Result truncation is a function inside gather. Citation formatting is a function inside finalize. Each was a node in an earlier draft of Atlas, and each was deleted for the same reason: nothing was supposed to happen at the boundary.
Six nodes for nine steps. The gap between those two numbers is this chapter.
References
- Node retry policies, per-node retry configuration in LangGraph.js.
Part VII ends here. Next: Tools Are APIs for Models, Part VIII, and the design of what an agent is permitted to do.
Takeaways
- A node is a boundary. Draw one only when you can name what happens there: a checkpoint, a pause, a retry policy, an emitted event, or the structural guarantee that every path crosses it.
- "It feels like a step" describes a function. Steps are free; boundaries are not.
- Durability is a graph-wide dial (
sync/async/exit), so it cannot make one noisy node cheap without weakening the guarantee you wanted elsewhere. - Node names are part of your position schema. Splitting a node is a migration for anything in flight, which makes fine-grained graphs expensive to refactor.
- The one hard rule: a node that can pause must contain exactly one side-effecting call, because resume re-runs the node from the top rather than from the
interrupt(). - The functional API is the tier most teams skip. You get ordinary control flow, with checkpointing and interrupts, and no schema, reducers, or edges. You trade away an enumerable topology, and time travel. The second one is structural, because tasks update the entrypoint's checkpoint rather than creating their own.
- Visibility is a tracing concern. Instrument a pure transform with a span; do not promote it to a node to see it.
- A graph is worth it at two of three: more than one run shape, something must survive the process, or a reader who is not the author. Adopting one for legibility alone generates a picture of a straight line.
- Idempotency fixes repeated effects, not node count. Atlas's
finalizeis the example.