Key Takeaways
MemorySaveronly survives one process — the moment you run more than one worker or need a restart-safe workflow,PostgresSaveris a required swap, not an optimization- The single biggest source of production outages in naive LangGraph deployments is holding the HTTP connection open for the graph run — return the
thread_idimmediately and run the graph asynchronously instead- The supervisor pattern (one router agent, many worker subgraphs) is the right production default for any workflow touching real data, money, or compliance — the audit trail is built in
interrupt()pauses a graph node and returns control to the caller; resuming withCommand(resume=...)re-executes the node from the top, so nothing before theinterrupt()call can be non-idempotent- Prodinit builds production multi-agent systems on LangGraph — including a 67-agent orchestrator-specialist system, where the checkpointer and durable state design were the difference between a system that survives a restart and one that doesn't
LangGraph in production breaks in a specific, predictable way: it works flawlessly in a notebook with MemorySaver and a single synchronous call, then falls over the first time two workers run at once or a deploy restarts mid-session. The framework isn't the problem. The gap is between a graph that runs and a graph that's actually built to persist, scale, and recover.
LangGraph in production requires three architectural decisions the tutorials skip: a durable checkpointer (PostgresSaver, not MemorySaver), graph execution decoupled from the HTTP request lifecycle, and a deliberate multi-agent topology — supervisor, swarm, or parallel fan-out — chosen for the workload, not defaulted into. Get these three right and LangGraph holds up under real concurrent load; skip them and it works right up until it doesn't.
Checkpointer Choice: The Decision That Isn't Optional
Every LangGraph deployment needs a checkpointer, and the choice between MemorySaver and PostgresSaver is not a performance tuning knob — it determines whether your in-flight runs survive a restart at all. MemorySaver keeps state in the process's memory: fine for local development, unusable the moment you run more than one worker or redeploy while sessions are active, because every in-flight graph run is lost.
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # creates required tables — run once
graph = builder.compile(checkpointer=checkpointer)
PostgresSaver makes checkpoint state shared and durable across every worker, which is what lets you scale horizontally and survive a deploy without dropping active sessions. In production, pass an existing connection pool rather than a single connection — a starting formula for sizing is workers × pool.max_size < postgres.max_connections × 0.7, and past a handful of replicas, put PgBouncer in front so you can add workers without renegotiating Postgres connection limits directly.
from langgraph.checkpoint.postgres import PostgresSaver
from psycopg_pool import ConnectionPool
pool = ConnectionPool(conninfo=DB_URI, max_size=10)
with pool.connection() as conn:
checkpointer = PostgresSaver(conn)
checkpointer.setup()
Every invocation needs a meaningful thread_id so checkpoints and any human-in-the-loop pauses attach to the correct conversation or workflow instance — a random or reused thread_id silently corrupts unrelated sessions' state.
Decouple Graph Execution From the HTTP Request
Holding an HTTP connection open for the duration of a graph run is the single biggest source of production outages in naive LangGraph deployments. A multi-step agent graph with tool calls and model round-trips can run for seconds to minutes — long enough to hit gateway timeouts, exhaust a web server's worker pool under concurrent load, or leave a client with no way to reconnect after a dropped connection mid-run.
The fix is to treat graph execution as a background job, not a synchronous request handler:
@app.post("/agent/run")
async def start_run(request: RunRequest):
thread_id = str(uuid4())
background_tasks.add(run_graph.delay(thread_id, request.input)) # Celery/Arq/etc.
return {"thread_id": thread_id} # client polls or subscribes for updates
The client receives a thread_id immediately and polls a status endpoint or subscribes to a stream for results, while the graph itself runs in a worker process (Celery, Arq, or an equivalent task queue) that isn't tied to the request's lifetime. This is also what makes interrupt()-based human-in-the-loop practical — a paused graph doesn't need to hold anything open while it waits for a human to respond, potentially hours later.
Set recursion_limit on every invoke and treat GraphRecursionError as an expected, catchable failure mode rather than an unhandled crash — LangGraph raises it automatically once a run exceeds the configured step ceiling, and production code needs to catch it and mark the run as errored rather than let it propagate.
Choosing a Multi-Agent Pattern: Supervisor, Swarm, or Parallel Fan-Out
LangGraph supports three distinct multi-agent topologies natively, and picking the wrong one for the workload is a common source of both cost overruns and debugging pain. Each solves a different coordination problem, and they compose within a single system.
Supervisor is one router agent that decides which worker subgraph handles each step and synthesizes the results — the LangGraph implementation of the orchestrator-specialist pattern. It costs more tokens per run because every routing decision goes through the supervisor, but the single routing point makes debugging tractable and the audit trail is built in. For any workflow touching real data, money, or compliance, start here.
supervisor = StateGraph(SupervisorState)
supervisor.add_node("router", route_to_worker)
supervisor.add_node("research_agent", research_subgraph) # self-contained subgraph
supervisor.add_node("write_agent", writer_subgraph) # self-contained subgraph
supervisor.add_conditional_edges("router", pick_worker, {
"research": "research_agent",
"write": "write_agent",
})
Swarm removes the central router — agents hand off directly to each other using Command objects returned from handoff tools, with Command(goto="next_agent", graph=Command.PARENT) telling LangGraph to jump to a node in the parent graph. It's lower-overhead than a supervisor for peer-to-peer collaboration, but the distributed handoff logic is harder to trace after the fact — reserve it for workflows where the token savings matter more than a single audit trail.
Parallel fan-out uses the Send API to dispatch independent work to multiple worker nodes simultaneously, then merges results through state reducers once every branch completes:
from langgraph.types import Send
def fan_out(state: GraphState):
return [Send("researcher", {"query": q}) for q in state["queries"]]
Each Send target can be its own self-contained subgraph — the pattern that turns a set of independent research or retrieval tasks into a single round of parallel execution instead of a sequential loop, which is exactly the shape a 67-agent orchestrator-specialist system Prodinit built relies on for its independent worker steps.
Human-in-the-Loop: interrupt() and Command(resume=...)
interrupt() is the current LangGraph primitive for pausing a graph node and returning control to the caller — the pattern replaces older node-level interrupt exceptions with a function you call inline wherever a human decision is needed, most commonly right before a tool call that does something real: sending an email, running a write query, or placing an order.
from langgraph.types import interrupt, Command
def request_approval(state: AgentState):
decision = interrupt({"action": state["pending_action"], "reason": state["reason"]})
return {"approved": decision}
# resuming from the caller side:
graph.invoke(Command(resume=True), config={"configurable": {"thread_id": thread_id}})
The behavior that trips up most first implementations: when a graph resumes, the node restarts from the beginning, and every line before the interrupt() call re-executes — including in subgraphs, where both the parent node and the subgraph node re-run. Any non-idempotent operation placed before interrupt() (an append to a list, a side-effecting API call) runs twice. Keep everything before the interrupt pure, and do the side effect after the resume, not before the pause.
A checkpointer is mandatory for this to work at all — without one, there is no persisted state for the graph to pause into or resume from, which is the most common reason interrupt() fails silently for teams that skip the PostgresSaver swap covered above. For production, add a TTL-based expiry job that scans for threads left un-resumed past a threshold (24 hours is a common default) and marks them abandoned rather than leaving paused runs to accumulate indefinitely.
LangGraph Production Decision Framework
| Decision | Choose this when | Choose the alternative when |
|---|---|---|
MemorySaver vs PostgresSaver | Never in production | Always PostgresSaver once more than one worker or a restart-safe run matters |
| Synchronous request vs background job | Never for multi-step graphs | Always decouple — background task queue, poll or stream for results |
| Supervisor vs Swarm | Workflow touches real data, money, or compliance; debuggability matters | Peer-to-peer collaboration where token cost matters more than a single audit trail |
Sequential vs Send fan-out | Steps depend on each other's output | Steps are independent and can run concurrently |
interrupt() placement | Always after any non-idempotent code in the node | Never before a side effect you can't safely repeat |
Get Prodinit's AI engineering guides in your inbox
Deep-dives on production LLMs, voice AI, and MLOps — published weekly. No sales emails.
Frequently Asked Questions
Yes. LangGraph provides the graph-based execution model, persistence layer, and native support for supervisor, swarm, and parallel fan-out multi-agent topologies that production systems need. The gap that trips teams up isn't the framework — it's skipping the operational decisions: a durable PostgresSaver checkpointer instead of MemorySaver, decoupling graph execution from the HTTP request lifecycle, and choosing a multi-agent pattern deliberately rather than defaulting into one.
You're almost certainly using MemorySaver, which keeps checkpoint state in the process's memory rather than a durable store — a restart or a second worker process has no access to it. Swap to PostgresSaver (or an equivalent durable backend); the code change is typically a one-line compile-time swap, and it's the single highest-impact fix for LangGraph state loss in production.
Supervisor routes every decision through one central agent that delegates to workers and synthesizes results — it costs more tokens per run but gives you a single, traceable point for debugging and an audit trail. Swarm removes the central router; agents hand off directly to each other via Command(goto=..., graph=Command.PARENT), which is more token-efficient but harder to trace. Use supervisor for anything touching real data, money, or compliance; swarm for lower-stakes peer collaboration.
Call interrupt() inside the node, right before the action that needs approval — it pauses execution and surfaces the interrupt payload to the caller via the graph's __interrupt__ output. Resume by re-invoking the graph with Command(resume=<value>) against the same thread_id. This requires a checkpointer to be compiled into the graph, since interrupt/resume depends entirely on LangGraph's persistence layer to hold the paused state.