teachyou.ai academy
← All posts
LangGraph

LangGraph Send API: Dynamic Parallel Node Execution

Ira Menon · Jun 16, 2026 · 14 min read

Why Static Graphs Break When the Fan-Out Count Is Unknown

Every LangGraph builder eventually hits the same wall. You've drawn a clean graph on paper: a node reads a document, splits it into chunks, and sends each chunk to a "summarize" node. That works fine when you know at design time exactly how many chunks there will be. But real workloads don't cooperate. A user uploads a 3-page PDF today and a 300-page PDF tomorrow. A research agent might generate 2 sub-questions or 20. A code-review agent might need to review 1 file or 50 files in a single pull request.

Standard LangGraph edges are defined statically when you build the graph — you write graph.add_edge("split", "summarize") once, and that edge topology is fixed before you ever run the graph. There is no built-in mechanism in a plain edge to say "run this node N times, once per item in a list I only discovered at runtime, and do it in parallel." Conditional edges let you choose *which* node runs next, but they still route to a single next step per invocation, not to a dynamically-sized batch of parallel invocations.

This is exactly the gap the Send API was built to close. Send is a primitive in LangGraph that lets a node's routing function return not just "go to node X" but "go to node X, N times, each with its own distinct input state." Each of those Send objects becomes an independent, parallel execution of the target node, and LangGraph's runtime schedules them concurrently, collects their outputs, and merges them back into the parent graph's state using your reducers.

If you've built pipelines with LangGraph Tutorial-style course material and hit the "how do I fan out over a list I don't know the size of" problem, this article is your answer. We'll build working map-reduce pipelines, nested subgraph fan-outs, and state-merging patterns using the real Send API — no toy pseudocode.

What Send Actually Is: Anatomy of the Primitive

Send lives in langgraph.types and is deceptively small. It's a two-field object:

from langgraph.types import Send

Send(node="summarize_chunk", arg={"chunk": "some text chunk", "chunk_id": 3})

The node field is the name of the target node — a string that must match a node already registered in your graph. The arg field is the state that specific invocation of the node will receive. Crucially, this is not the parent node's full state passed through unchanged — it's an entirely separate, isolated piece of state you construct yourself.

The way you use Send is by returning a list of `Send` objects from a conditional edge function (the same kind of function you'd normally use with add_conditional_edges). Instead of returning a string (the name of the next node) or a list of strings, you return a list of Send instances. LangGraph interprets this as "spin up one parallel execution of each entry, feed it exactly this arg as its input state, and wait for all of them to finish before continuing."

Here's the minimal skeletal shape:

def route_to_workers(state: OverallState):
    return [
        Send("worker_node", {"item": item})
        for item in state["items"]
    ]

graph.add_conditional_edges("dispatcher", route_to_workers, ["worker_node"])

Notice the third argument to add_conditional_edges — the list of possible destination nodes. LangGraph needs this for graph validation, so it can confirm worker_node is a real node even though the actual number of times it runs isn't known until runtime.

This is the core mental model: `Send` decouples "how many times a node runs" from "how the graph is wired." The wiring says "dispatcher can send to worker_node." The runtime, driven by your routing function, decides it should be 1 time, 5 times, or 500 times, based on data.

Building a Map-Reduce Pipeline with Send

The classic use case for Send is map-reduce: split a big task into independent pieces (map), process each piece in parallel, then combine the results (reduce). Let's build a document summarization pipeline that splits an arbitrary document into chunks, summarizes each chunk in parallel, and then produces a final combined summary.

First, the state definitions. We need an overall state that tracks the document and the final output, and a separate state shape just for what each parallel worker receives:

import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send


class OverallState(TypedDict):
    document: str
    chunks: list[str]
    summaries: Annotated[list[str], operator.add]
    final_summary: str


class ChunkState(TypedDict):
    chunk: str
    chunk_id: int

The Annotated[list[str], operator.add] on summaries is doing critical work here. Each parallel worker will return a partial update containing one summary. Because LangGraph uses operator.add as the reducer for that key, every worker's single-item list gets concatenated onto the shared summaries list rather than overwriting it. Without a reducer like this, parallel writes to the same state key would conflict or clobber each other.

Now the nodes. The splitter breaks the document into chunks and stores them in state. The routing function then reads those chunks and emits one Send per chunk:

def split_document(state: OverallState):
    text = state["document"]
    chunk_size = 500
    chunks = [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]
    return {"chunks": chunks}


def route_to_summarizers(state: OverallState):
    return [
        Send("summarize_chunk", {"chunk": chunk, "chunk_id": i})
        for i, chunk in enumerate(state["chunks"])
    ]


def summarize_chunk(state: ChunkState):
    # In production, this calls an LLM. Kept deterministic here for clarity.
    chunk = state["chunk"]
    summary = f"[chunk {state['chunk_id']}] {chunk[:80].strip()}..."
    return {"summaries": [summary]}


def combine_summaries(state: OverallState):
    combined = "\n".join(state["summaries"])
    return {"final_summary": combined}

Note that summarize_chunk is typed against ChunkState, not OverallState. It only ever sees chunk and chunk_id — exactly what we passed in the Send object's arg. It has no visibility into the full document or the other chunks. This isolation is a feature: each parallel branch is a pure function of its own slice of data, which makes reasoning about correctness and writing tests dramatically simpler.

Wiring it together:

builder = StateGraph(OverallState)
builder.add_node("split_document", split_document)
builder.add_node("summarize_chunk", summarize_chunk)
builder.add_node("combine_summaries", combine_summaries)

builder.add_edge(START, "split_document")
builder.add_conditional_edges(
    "split_document",
    route_to_summarizers,
    ["summarize_chunk"],
)
builder.add_edge("summarize_chunk", "combine_summaries")
builder.add_edge("combine_summaries", END)

graph = builder.compile()

result = graph.invoke({"document": "..." * 50})
print(result["final_summary"])

Run this with a document that produces 3 chunks and LangGraph fires off 3 concurrent invocations of summarize_chunk. Run it with a document that produces 40 chunks and it fires off 40. You never touch the graph wiring — only the data determines fan-out width.

Send Inside Conditional Edges vs. Regular Routing

It's worth being explicit about what changes when you switch a conditional edge function from "normal" routing to Send-based routing, because the two look almost identical syntactically but behave very differently at runtime.

A normal conditional edge function returns a string or list of strings:

def normal_router(state: OverallState):
    if state["needs_review"]:
        return "human_review"
    return "finalize"

Here, whichever node name comes back receives the current full state as its input — the same state object flowing through the rest of the graph. There's no forking of data; it's just a fork in control flow.

A Send-based router returns Send objects instead:

def send_router(state: OverallState):
    return [Send("finalize", state) for _ in range(3)]

Even in this contrived example where we're not slicing the state at all, this behaves completely differently from a normal edge. It launches three separate parallel invocations of finalize, each getting a copy of state as its own isolated input. Compare that to return ["finalize"] from a normal router, which routes once, with one execution.

The practical rule: use plain string returns when you're choosing between different next steps (branching), and use Send when you're choosing how many times to run the *same* step with different data (fan-out). Mixing the two in a single router function is also legal — you can return a mix of plain node-name strings and Send objects in the same list, though most codebases keep these concerns separate for readability.

Handling Nested Fan-Out with Subgraphs

Fan-out gets more interesting once each parallel branch is itself a multi-step subgraph rather than a single function. Suppose each "chunk" in our pipeline doesn't just need summarizing — it needs summarizing, then keyword extraction, then a quality check, all as a mini-pipeline. You don't want to inline all three steps into one giant function; you want a reusable subgraph.

The pattern is: build the subgraph normally, compile it, and use the compiled subgraph as a node in the parent graph. Send targets that node by name exactly as it would target a plain function node.

class ChunkState(TypedDict):
    chunk: str
    chunk_id: int
    keywords: list[str]
    quality_ok: bool


def extract_keywords(state: ChunkState):
    words = [w.strip(".,").lower() for w in state["chunk"].split() if len(w) > 6]
    return {"keywords": list(set(words))[:5]}


def quality_check(state: ChunkState):
    return {"quality_ok": len(state["chunk"]) > 20}


def summarize_step(state: ChunkState):
    return {"chunk": f"[summary] {state['chunk'][:80].strip()}"}


chunk_builder = StateGraph(ChunkState)
chunk_builder.add_node("summarize_step", summarize_step)
chunk_builder.add_node("extract_keywords", extract_keywords)
chunk_builder.add_node("quality_check", quality_check)
chunk_builder.add_edge(START, "summarize_step")
chunk_builder.add_edge("summarize_step", "extract_keywords")
chunk_builder.add_edge("extract_keywords", "quality_check")
chunk_builder.add_edge("quality_check", END)

chunk_subgraph = chunk_builder.compile()

Now plug that compiled subgraph directly into the parent graph as the fan-out target:

class OverallState(TypedDict):
    document: str
    chunks: list[str]
    processed: Annotated[list[dict], operator.add]


def route_to_subgraph(state: OverallState):
    return [
        Send("chunk_pipeline", {"chunk": c, "chunk_id": i, "keywords": [], "quality_ok": False})
        for i, c in enumerate(state["chunks"])
    ]


parent_builder = StateGraph(OverallState)
parent_builder.add_node("split_document", split_document)
parent_builder.add_node("chunk_pipeline", chunk_subgraph)
parent_builder.add_edge(START, "split_document")
parent_builder.add_conditional_edges("split_document", route_to_subgraph, ["chunk_pipeline"])
parent_builder.add_edge("chunk_pipeline", END)

graph = parent_builder.compile()

Each Send("chunk_pipeline", ...) triggers an entire independent run of the three-step subgraph, and LangGraph parallelizes across however many chunks exist. The subgraph internally still runs its steps sequentially (summarize, then keywords, then quality check), but across chunks, everything is concurrent. This composability — fan-out at the parent level, sequential logic inside each fanned-out unit — is what makes Send genuinely useful for production agent pipelines rather than just toy demos.

State Merging and Reducer Pitfalls

The single most common bug developers hit with Send is forgetting that every parallel branch writes back into the same shared state object, and without correct reducers, those writes silently overwrite each other instead of accumulating.

Consider this broken version of our summaries state:

class BrokenState(TypedDict):
    summaries: list[str]  # no reducer!

If ten parallel summarize_chunk invocations all return {"summaries": [some_summary]}, LangGraph has no instruction for how to combine ten different single-item lists into one final list. The default behavior for un-annotated keys is "last write wins," which means nine of your ten summaries silently vanish, and the bug is nondeterministic — it depends on execution order, which can vary run to run. This is the kind of bug that passes in dev with 2 items and fails mysteriously in production with 50.

The fix, as shown earlier, is always to annotate list-accumulating keys with a reducer:

from typing import Annotated
import operator

class CorrectState(TypedDict):
    summaries: Annotated[list[str], operator.add]

For cases where you need more control than simple concatenation — say, deduplication, or merging dictionaries by key — you write a custom reducer function instead of using operator.add:

def merge_dedup(existing: list[str], new: list[str]) -> list[str]:
    combined = existing + new
    seen = set()
    result = []
    for item in combined:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result


class DedupState(TypedDict):
    tags: Annotated[list[str], merge_dedup]

A second, subtler pitfall: don't try to read state that other parallel branches are writing, from *within* a parallel branch. Each Send-triggered execution is isolated — it cannot see partial results from its siblings mid-flight. If node B's logic genuinely depends on node A's parallel sibling result, that dependency needs to be resolved *after* the fan-out rejoins, not during it. Structure your graph so the "combine" node (like our combine_summaries) is the first place where cross-branch data becomes visible again.

Finally, watch for reducers on keys you pass into Send.arg versus keys the parent graph tracks. The arg dictionary you build for each Send is the *entire* input state for that invocation — if the target node's schema expects a key you forgot to include, you'll get a KeyError or a silently missing field at runtime, since LangGraph doesn't enforce that arg matches the target's TypedDict at the type-checker level (it's runtime dict access).

Conditional Fan-Out: Skipping Send Entirely When There's Nothing to Do

A practical detail that trips people up: what happens when the router function that returns Send objects encounters an empty list — say, a document with zero chunks, or a search that returned zero candidate documents to grade?

You handle it exactly like you'd expect: return an empty list, or route directly to a fallback node instead.

def route_to_summarizers(state: OverallState):
    if not state["chunks"]:
        return "combine_summaries"  # skip fan-out, go straight to reduce step
    return [
        Send("summarize_chunk", {"chunk": chunk, "chunk_id": i})
        for i, chunk in enumerate(state["chunks"])
    ]

This works because conditional edge functions in LangGraph can mix return types across calls — sometimes returning a plain string, sometimes returning a list of Send objects — as long as every possible destination was declared in the add_conditional_edges call's node list. Just remember to add "combine_summaries" to that destination list alongside "summarize_chunk" so graph validation doesn't reject the extra route:

builder.add_conditional_edges(
    "split_document",
    route_to_summarizers,
    ["summarize_chunk", "combine_summaries"],
)

This pattern matters more than it looks. In agentic pipelines, "zero items to process" is not an edge case you can defer — it's a routine outcome (a search with no results, a classifier that found nothing to flag, a diff with no changed files). Pipelines that assume fan-out always has at least one branch tend to hang or throw confusing errors in production the first time that assumption breaks.

Debugging Parallel Send Executions

Parallel execution is harder to debug than sequential execution because print statements from different branches interleave, and a stack trace from branch 7 doesn't tell you it was branch 7. A few practices make this tractable.

First, always include an identifying field (like our chunk_id) in the arg you pass to Send, even if the target node's core logic doesn't strictly need it. It costs nothing and turns "some summarize_chunk call failed" into "chunk_id 14 failed," which is immediately actionable.

def summarize_chunk(state: ChunkState):
    try:
        summary = call_llm_to_summarize(state["chunk"])
        return {"summaries": [summary]}
    except Exception as exc:
        # Always tag errors with the branch's identity
        return {"summaries": [f"[ERROR chunk {state['chunk_id']}: {exc}]"]}

Second, if you're using LangGraph's streaming API to observe execution (graph.stream(...) instead of graph.invoke(...)), each parallel branch emits its own stream events. Filter or tag by node name and any identifying state field to reconstruct per-branch timelines rather than trying to read the raw interleaved stream.

Third, set explicit limits. An unbounded fan-out driven by untrusted input (say, a user-uploaded file that somehow produces 50,000 chunks) can exhaust API rate limits, memory, or your LLM provider's concurrency caps almost instantly. Cap it defensively before the Send list is built:

MAX_PARALLEL_CHUNKS = 200

def route_to_summarizers(state: OverallState):
    chunks = state["chunks"][:MAX_PARALLEL_CHUNKS]
    return [
        Send("summarize_chunk", {"chunk": chunk, "chunk_id": i})
        for i, chunk in enumerate(chunks)
    ]

This single guard has saved more than one production pipeline from an accidental denial-of-service against its own LLM provider account.

When Send Is the Wrong Tool

Send is powerful, but it's not the answer to every "I need parallelism" question. If your fan-out width is genuinely fixed and known at graph-build time — say, you always want to query exactly three fixed retrieval sources — plain parallel edges (multiple add_edge calls from one node to several named nodes) are simpler and require no Send import at all. Reach for Send specifically when the *count* of parallel branches is a runtime value derived from data, not a constant your code already knows.

It's also worth being honest about cost: fanning out to 40 parallel LLM calls means 40 concurrent API requests. That's 40x the token throughput demand at once, and if your provider or your own downstream service enforces per-minute rate limits, you'll want batching, backoff, or a semaphore-like cap (as shown above) rather than naively sending everything at once.

Wrapping Up

The Send API solves a real, specific problem: dynamic-width parallel fan-out where the number of parallel branches is a runtime property of your data rather than a design-time property of your graph. The mechanics are small — a Send(node, arg) object, returned in a list from a conditional edge function — but the implications ripple through state design (you need reducers like operator.add for every key parallel branches write to), subgraph composition (compiled subgraphs work as Send targets exactly like plain function nodes), and operational safety (cap fan-out width, tag branches for debugging, handle the empty-list case explicitly).

Map-reduce summarization, parallel document grading in RAG pipelines, multi-file code review, and parallel sub-question research in agentic search are all the same shape underneath: split, fan out with Send, reduce with a shared-state reducer. Once that shape is second nature, you'll recognize it everywhere in agentic system design.

If you want to go deeper — building full multi-agent architectures, combining Send with checkpointing and human-in-the-loop interrupts, and deploying these graphs to production — that's exactly what we cover hands-on in the LangGraph Tutorial course on teachyou.ai, with real projects instead of toy snippets.