Fan-out in LangGraph: Send() and the join that bit me
The due-diligence pipeline I built for a fintech client this spring has one step that dictates its whole architecture: given a company, check every source we hold on it. Sometimes that's four sources, sometimes eleven: registry filings, news archives, sanctions lists, a court-records scraper. You can't draw eleven static branches in a graph when you don't know the count until runtime. That's the exact problem LangGraph's Send API exists to solve, and it solves it well. It also bit me, and the bite is the useful part of this post.
what Send actually does
A conditional edge normally returns the name of the next node. Return a list of Send objects instead, and the runtime schedules one task per item, each with its own private payload:
def dispatch(state):
return [Send("check_source", {"src": s, "idx": i})
for i, s in enumerate(state["sources"])]
builder.add_conditional_edges("plan", dispatch, ["check_source"])
Each branch runs the same node function against its own slice of work. Results flow back through a reducer on shared state (mine is findings: Annotated[list, operator.add]), and the node after the fan-out doesn't fire until every branch has finished. That barrier is the part LangGraph gets emphatically right; in four months I've never seen a partial join. One sharp edge: two parallel branches writing the same plain, reducer-less key is a hard runtime error, so fan-out quietly forces you to do state design properly. I count that as a feature.
the join that bit me
Here's what the barrier does not promise: order.
I assumed findings[0] would be the registry result, because I dispatched the registry check first. My compile node deduplicated overlapping facts by keeping the first occurrence. First meant registry, meant most trusted, in my head. For roughly nine runs out of ten, that's exactly what happened.
On the tenth, branches completed in a different order, a news snippet became the canonical record for one fact, and a report went out leading with an office address about two years stale. The client's analyst caught it. I didn't.
My first theory was sampling. I pinned temperature to zero, re-ran the batch, and lost two evenings to that idea before accepting what the flakiness was telling me: deterministic model, nondeterministic pipeline. When I finally logged the join's input, the merged list tracked completion order, not dispatch order, and nothing in the contract ever said otherwise.
The barrier guarantees your join sees everything. It says nothing about the order any of it arrived in.
The fix was three lines. Carry an index in every Send payload, sort the findings by it at the top of the join node, done. The general rule costs nothing to say and apparently everything to remember: a list built by a concurrent append reducer is a set wearing a misleading type. If order matters, it belongs in the data, never in the scheduling. I've known this since thread pools. A graph just looks so orderly on a whiteboard that I forgot it anyway.
when fan-out pays, and when it just burns
The wall-clock case is boring and decisive. Our source checks are I/O: HTTP round trips to slow registries, anywhere from 8 to 40 seconds each. Serial, a bad company took over four minutes on this step alone; fanned out, it takes about as long as the slowest branch. Analysts noticed the same day. That's the honest reason to reach for Send: I/O-bound subtasks with no dependencies between them, the same fire-and-join shape from async agent architecture.
What fan-out doesn't do is save a single token. Nine branches cost the same parallel as serial. You just pay faster, and the burst can slam into provider rate limits, so your parallelism ends up queuing at the API anyway. We ate 429s for a week until I capped the run with max_concurrency=4. And a wasteful prompt in a branch node gets multiplied by every branch, so I now tune that node on a single item before letting it fan.
One more honest cost: interleaved branch traces are genuinely harder to read than a plain loop's (the same trade subagent isolation makes in coding agents). I'd still ship Send again tomorrow, just with the index in the payload from day one. Sorted.