Map-reduce is the shape almost every non-trivial agent graph eventually needs: fan out a task over N items, do the work in parallel, then combine the results. LangGraph supports it well — but it splits the pattern into two separate primitives, and nearly every fan-in bug I have seen comes from using the first and forgetting the second.

The map is the easy half#

The fan-out is the Send API. From a conditional edge you return a list of Send objects, and LangGraph launches one concurrent instance of the target node per item, each with its own slice of state:

def fan_out(state):
    return [Send("worker", {"item": x}) for x in state["items"]]

builder.add_conditional_edges("dispatch", fan_out, ["worker"])

Ten items, ten worker tasks in the same super-step. This part is intuitive and it mostly does what you expect. The trouble starts when those ten workers finish and you need exactly one node to run once, over all ten results.

The reduce is where graphs quietly break#

The obvious move is to draw an edge from worker to an aggregate node. It is also wrong. A normal edge marks its target eligible the moment any single upstream task reaches it. With parallel branches — or worse, branches of unequal length, where one mapped path is two nodes deep and another is five — the aggregator fires early, on whichever branch arrives first, and reduces over partial data. Often it fires repeatedly, once per arriving branch. You get an aggregate that is sometimes right, sometimes short, and never reproducible.

Deferred nodes are the fix. You register the aggregator with defer=True:

builder.add_node("aggregate", aggregate, defer=True)

A deferred node will not execute until every pending task in the graph has drained. So no matter how many workers you fanned out to, or how uneven their paths, aggregate runs once, at the very end, over the complete fan-in. Pair it with an accumulator channel — a state key Annotated with operator.add — so each worker appends its result and the deferred reducer reads the full list. That is the whole correct map-reduce shape: Send to map, a deferred node with an accumulator to reduce.

A plain edge asks "has a branch arrived?" A deferred node asks "has everything finished?" Those are different questions, and only the second one reduces correctly.

The part the tutorials skip: defer is a barrier, not a resolver#

Here is the one idea worth carrying out of this piece. defer=True is not a dependency resolver. It does not build a graph of which nodes feed which and schedule accordingly. It is a scheduling barrier on the super-step queue — its entire semantics are "run me when nothing else is left to run." That is exactly enough for the classic single-gate fan-in, and it is why it works so cleanly there.

It is also why it frays at the edges. Because defer only knows "is the queue empty," it cannot distinguish a branch that genuinely feeds the aggregator from an unrelated subtree that just happens to still be running — so unrelated work can delay your reducer (plain head-of-line blocking). And in graphs where a deferred node has both a deferred ancestor and a direct edge from another ancestor, the scheduler can de-queue it early and run it twice — first prematurely, then again after the real dependency completes. That is not a hypothetical; it is tracker issue #6005, whose reporter puts it bluntly: defer "only addresses queuing, not intelligent lineage-based parallelism." Composing defer with Command and conditional edges has its own documented broken cases.

None of this makes deferred nodes a bad tool. It makes them a specific tool. Use defer=True for what it is: a global "wait for the graph to go quiet" gate in front of a single reducer whose inbound edges are simple. Keep the reducer's ancestry uncomplicated, keep its state a clean accumulator, and it is the right, boring answer.

When you instead need true ordering across nested or asymmetric subgraphs — where "wait for everything" is too blunt and "wait for these specific branches" is what you mean — reach for a subgraph that encapsulates the parallel section, or an explicit join node you control. Those give you dependency ordering you can actually reason about, instead of hoping the queue drains in the shape you assumed. The mistake is not using defer=True. The mistake is thinking it understands your graph. It only understands the clock.