LANGGRAPH ยท August 27, 2026 ยท 10 min read
LangGraph Subgraphs: When to Split a Graph and When Not To
On LangGraph, a subgraph buys you an observable boundary, not a state boundary. Why our own graphs got split, and what the frontend sees while a child runs.
Most people reach for a LangGraph subgraph expecting a state boundary, and what they actually get is an observable one.
If your question is "single agent, approval loop, or multi-agent?", that's an architecture question and the decision matrix already answers it. This post is about the layer underneath: what a subgraph actually changes at runtime, why our own graphs got split, and what the frontend sees while a child is running.
What does a subgraph actually give you?
Nested execution and namespaced stream events. That's the honest list.
Let's start with the canonical pattern, which is small.
Compile a child StateGraph, then add the compiled graph as a node in the parent:
Two things change. The child runs as its own graph, with its own nodes and its own step sequence rather than being flattened into the parent's. And LangGraph emits the child's stream events under a namespace, so a consumer can tell parent output from child output.
Here's the part I think gets assumed and shouldn't: state isolation isn't a third.
If parent and child share MessagesState, the child appends to the same message list the parent is building.
Nothing about add_node fenced anything off.
Isolation is something you design โ give the child its own state schema, then map in at the boundary and map the result back out.
That's a decision you make and maintain, not a property compile() hands you.
We ship one graph that does exactly that, and because it's a capability demo built to show the primitive, it's a clean look at the shape.
Its child state schema has no messages key at all.
Parent and child share exactly two keys, research_topic and research_brief, so the child is handed a topic and hands back a brief โ it can't read the transcript, and it can't append to one.
That boundary is real, and none of it came from compile().
It came from writing two TypedDicts and being deliberate about what they share.
What about context windows and error boundaries?
Those are real reasons to split โ our own docs lean on them. The subgraphs guide points at per-task context windows and failure containment as reasons to reach for subagents, and the docstring on our own research child's only node calls it "a focused contractor."
But look at where each one actually comes from.
A narrow context window is a consequence of what you pass into the child's ainvoke โ you get it by handing over a topic instead of a transcript.
An error boundary is a consequence of how the parent handles a failed child call, and a node-level retry wraps any node, plain function or compiled graph alike.
Reuse across parents is a consequence of the child being a value you can reference twice.
You can have all three without ever compiling a child graph, and you can compile a child graph and get none of them.
There is one more, and it's worth stating because it looks like a counterexample. Wire the child in as a node under a parent that has a checkpointer, and the child's steps get checkpointed under its namespace โ which is what lets you interrupt and resume at child granularity. Notice that's the namespace again, doing a second job.
Why do people really split?
In our own repo, the honest answer is: so the frontend can see the delegation.
That's a claim about our own graphs, not a law of the framework โ and one of them splits for a different reason entirely, which I'll get to. But it's a natural experiment rather than a portfolio โ nobody wrote these to prove a point about subgraphs, and the constraint that drove them, a frontend that renders per-child progress, isn't specific to us.
Let's look at what we wrote down at the time.
Here's the comment sitting above the research subagent in our canonical examples/chat graph:
That's not a state argument. It's a visibility argument.
The design doc for that feature is blunter still. Here's the alternative it rejected:
Then there's the conversion.
Our cockpit/chat/subagents demo originally ran its three specialists as a flat in-process helper, and was rewritten to dispatch a real compiled child graph โ because the flat version emitted no namespace events, so subagents() stayed empty and no card rendered.
A working feature was restructured so a UI card would appear.
In both of those graphs the compiled child is invoked from inside a @tool body, not wired in as a plain node.
That's deliberate: the tool call is what the tracker registers, and our own docs are blunt that plain subgraph nodes don't show up in that map at all.
Which cuts the other way from how it sounds โ plain add_node subgraphs make the point sharper, not weaker.
Those still get a namespace, so they're still observable in the raw stream.
They just don't get a name, so nothing downstream can attribute them to anything.
The subgraph is what makes the events observable; the tool call is what gives them an identity.
What does the frontend see while a child runs?
Namespaced events โ and nearly everything interesting downstream follows from that one fact.
What the wire looks like
Let's take it from the wire inward. The event type carries the namespace after a pipe, so the base type is the part before it:
Our transport requests those child streams by default โ streamSubgraphs is true unless you turn it off.
That's the LangGraph JS SDK's own option name, passed straight through, and worth knowing if you're coming from the Python API, where the in-process graph.stream() equivalent is the subgraphs=True kwarg.
The terminal-event hazard
A child graph terminates before the parent does, and a child's terminal event looks an awful lot like the parent's.
Without a namespace guard, that child terminal marker gets read as "the run finished" and closes out the parent's still-streaming assistant message.
We guard it by refusing namespaced events as top-level terminal evidence, and there's a test that feeds a namespaced terminal marker in and asserts the parent message settles with outcome interrupted rather than success.
If you ever write a transport against this stream yourself, that's the bug you'll hit, and it will look like truncation rather than a namespace bug.
Where child text goes
Into your main transcript, by default.
Our filterSubagentMessages is off unless you set it, so a child's tokens flow into messages() alongside the parent's.
That isn't a quirk of our config. Any consumer reading a namespaced stream has to decide what a child's tokens mean, and "append them like everything else" is the path of least resistance โ so unless something opts out, child text lands in the parent transcript and the same content renders twice.
How does a child get attributed?
By id โ and this is the part I find well-designed: the namespace segment is the identifier.
tools:<id> carries the parent tool call id, so the tracker slices the prefix off and looks the id up directly against what it recorded when the tool call came through.
Marking a child running and routing its messages need no matching at all.
There is also a description-comparison ladder โ exact match on the tool call's description argument, then substring either direction, then a last-resort fallback to any unmapped subagent still pending or running.
It only runs for children whose state opens with a human message, and none of the graphs we ship reach it.
The ones dispatched through a tool call invoke the child with an empty message list, so the first message in child state is the AI response.
The one wired in as a plain node doesn't keep a messages key in child state at all.
Treat that path as untested rather than as the mechanism.
The general point survives, though, and it's the one worth carrying to any protocol. A consumer mapping child runs onto delegations is doing string matching unless the protocol gives it an id. LangGraph gives it an id โ which is why the ladder is vestigial here and would be load-bearing in a fan-out graph with look-alike children.
One limit, though: only the first tools: segment of a namespace is read.
A subagent that itself delegates will have its inner events attributed to the outer tool call.
Nothing in this repo exercises deeper nesting, so don't build on it.
When should you not split?
When there's no observable boundary to draw and no genuinely divergent state.
The cleanest evidence I have is a control group we didn't set out to build.
Our cockpit/ag-ui/subagents capability ships the same three-subagent feature as the LangGraph one โ and it's a LangGraph StateGraph too, same framework, same orchestrator-plus-task-tool shape, same three roles, same cards in the UI โ with no subgraph anywhere.
Its module docstring says so outright:
The thing that differs is the transport: AG-UI's already carries a first-class delegation event.
So so the specialists stayed a flat async helper and progress reaches the frontend as a custom event dispatched from the tool body.
The subgraph was never required by the feature. It was required by the transport.
You could dispatch custom events from the LangGraph graph too โ nothing stops you, and adispatch_custom_event is a LangChain primitive, not an AG-UI one.
What namespaces buy is that you don't have to.
The boundary emits its own identity for free, and a transport that reads it works against any graph rather than any graph that remembered to instrument itself.
Staying flat wasn't free. There's no separate state schema to isolate anything into, and no child step sequence โ every specialist gets the parent's shape, one LLM call wide. What it bought was one fewer graph for a feature that renders identically.
That's the test I'd apply. If your transport already has a way to say "a child is working right now," or your UI doesn't render per-child progress at all, then a subgraph is a boundary you now have to defend: an extra state schema, mapping at both edges, and one more place to look when a message goes missing.
And splitting because a region of the graph feels like a separate concern isn't a reason on its own. A node is already a unit.
So when does a split earn itself?
When the child really is a different graph โ and the repo has exactly one of those, which is the case I owe you after arguing the other side this whole time.
Our examples/ag-ui demo runs on that same AG-UI transport, and it emits the same subagent_activity events from the tool body.
So it isn't buying observability; it already had it.
It compiles a child graph anyway.
Look at what the child is, though.
It has its own agent โ tools โ agent loop with conditional edges and an iteration cap โ a different control flow from the parent's, not a slice of it.
And here's the part that took me a second read to see.
A custom child state schema doesn't discriminate at all: the two graphs I just used as observability evidence also define their own child TypedDicts.
But both of those children are one node and a straight line, so the schema is really just an argument list with a type on it.
So it's the control flow, not the schema.
A child that carries a topic string is a function call wearing a graph costume.
A child that loops until it's satisfied is a graph.
Conclusion
Split when something outside the graph needs to see the child run as its own thing โ a card, a progress panel, per-child streaming.
Split when the child has its own control flow โ a loop, a branch, a stopping condition the parent doesn't have โ and you're willing to own the mapping at both edges.
Don't split for tidiness, and don't assume the split isolated state: wire a child in as a node on a shared MessagesState and it appends straight to the transcript the parent is building.
The architecture matrix covers the tiering question, the subgraphs guide has the composition and subagents() wiring, and What injectAgent() Actually Returns walks the signal surface those child streams land in.
If you've split a graph for a third reason โ not observability, and not a child that's genuinely its own graph โ I'd like to hear it. Those are the two I've been able to justify; I doubt they're the only two that exist.