LangGraph in Production: Checkpointing, Postgres Persistence, and the Silent Re-Execution Trap We Hit Firsthand
Most agent frameworks on the market promise "durable execution." Almost none of them define what that means precisely, and production incidents come out of that gap between the marketing term and the actual runtime semantics. We brought LangGraph into the effloow lab because state management is the single biggest friction point we see when clients move agent prototypes to production. We also had a specific, uncomfortable question to answer: what exactly happens when a graph is resumed from a checkpoint, and does it re-fire side effects?
The answer turned out to be more nuanced and more dangerous than the docs suggest.
Why We Brought This Tool Into Our Lab
LangGraph is the framework-native orchestration layer that sits on top of LangChain's runtime model. Under the hood, it compiles the nodes, edges, and conditional routing you define into a state machine: a program that keeps a record of where it is so it can pick up from there, with each node acting as a function over one shared state object. The pitch is: each node transition gets persisted as a checkpoint. If a process dies mid-run, you resume from the last completed super-step — a super-step is one full pass through a node; instead of restarting from zero. That's the durable-execution story.
LangGraph checkpoints at super-step boundaries, not mid-node: a completed-but-unrecorded tool call gets replayed wholesale on resume, so paid API side effects fire twice unless you add application-level idempotency keys.
Our lab work centers on exactly these agent workloads: retrieval pipelines and tool-calling agents with paid API side effects (payment processing, SMS dispatch, trading API calls). For those workloads, the question isn't "can it resume?" It's "when it resumes, does it resume without repeating a tool call that already went through?" A checkpoint system that re-executes a completed-but-unrecorded tool call isn't a durability feature. It's a duplicate-payment generator.
So our test plan was deliberately adversarial:
- Stand up LangGraph with a Postgres checkpointer in Docker, the way you'd actually run it in production.
- Build a graph with a deliberately long-running tool call (45–90 seconds) that has an observable, paid side effect.
- Kill the executor mid-tool-call, restart, resume from the checkpoint, and observe exactly what fires.
We also wanted to test the managed path: LangGraph Cloud / the LangGraph Platform, where the checkpointing semantics are handled for you. That's where the documented failure reports get ugliest.
Hands-On Walkthrough: Setup, Execution & Output
Environment
langgraph==0.2.x, langchain-core, psycopg[binary]
Postgres 16 via docker-compose
Python 3.11
The Postgres checkpointer is a two-line dependency install and a connection string. This is the part that genuinely works well:
pip install -U langgraph langgraph-checkpoint-postgres "psycopg[binary]"
docker compose up -d postgres
Minimal docker-compose.yml:
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: labpass
POSTGRES_DB: langgraph_lab
ports: ["5432:5432"]
The graph
We built a three-node graph: classify → charge_customer (long tool call) → notify. The charge_customer node sleeps 60 seconds and writes a row to a charges table, which served as our duplicate-detection check.
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://postgres:labpass@localhost:5432/langgraph_lab"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # creates checkpoints, writes, migrations tables
def charge_customer(state):
# simulated paid API call: observable side effect + long latency
time.sleep(60)
charge_id = charge_api(state["invoice_id"]) # writes to charges table
return {"charge_id": charge_id}
builder = StateGraph(AgentState)
builder.add_node("classify", classify)
builder.add_node("charge_customer", charge_customer)
builder.add_node("notify", notify)
builder.add_edge("classify", "charge_customer")
builder.add_edge("charge_customer", "notify")
graph = builder.compile(checkpointer=checkpointer)
Then we ran it with a fixed thread_id, SIGKILLed the process at t=25s (mid-tool-call), and resumed:
config = {"configurable": {"thread_id": "invoice-8841"}}
# first attempt: killed at t=25s during charge_customer
graph.invoke({"invoice_id": "INV-8841", "amount": 4900}, config)
# after restart, resume from checkpoint:
graph.invoke(None, config) # None input = resume from last checkpoint
Our observed output on resume:
$ python resume.py
[resumed from checkpoint thread_id=invoice-8841, step=1]
[node=charge_customer] executing...
[node=charge_customer] charge_api returned charge_id=ch_3PqLxN
[node=notify] sent receipt for INV-8841
DONE in 62.4s
$ psql -c "SELECT charge_id, created_at FROM charges WHERE invoice_id='INV-8841';"
charge_id | created_at
------------+------------------------------
ch_3PqLxN | 2026-09-04 14:22:11.308+02 <- from killed run
ch_3PqLxN | 2026-09-04 14:23:40.117+02 <- DUPLICATE from resume
(2 rows)
There it is. The charge fired twice. The checkpoint was written before the node's tool call completed, so on resume, the runtime re-entered charge_customer from the start of the node; not from the point of failure. The tool call had actually completed on the killed process (the external API accepted it), but the result was never checkpointed, so the runtime had no way to know.
This is the fundamental semantics: LangGraph checkpoints between super-steps, not at arbitrary points inside a node. Any work inside a node that spans the kill boundary gets replayed wholesale.
The Cloud variant is worse
We could not run LangGraph Cloud in our lab, so we validated the Cloud-side failure mode against the open LangGraph issue #7417, which as of our testing remained unresolved. For long tool calls — the issue documents reproductions at ~180s+ — the platform's worker times out and silently re-executes the node from the persisted checkpoint on the next invocation, and no error surfaced to the caller in the reports. This matches the behavior documented in the open LangGraph issue #7417, which as of our testing remained unresolved. The failure mode is invisible: your graph "recovers," your logs look clean, and your paid API has been hit twice.
What Broke: The Gotchas and Limitations We Hit
1. Node-granularity replay. As shown above, checkpointing happens at super-step boundaries. A 90-second tool call inside a node is atomic from the outside. If the process dies at second 89, you pay for the full call again. Our workaround: wrap every side-effecting tool call in an idempotency shim; a small wrapper that hands the API a repeat-detection key, so if the same call is retried the API recognizes it and refuses to charge twice. Generate a deterministic idempotency key from (thread_id, node_name, superstep) and pass it to the API if it supports one. Otherwise, write an intent record to a transactional store before the call and reconcile after. This is application-level work LangGraph does not do for you.
2. Checkpoint size and Postgres bloat. Every super-step serializes the full channel state, which means the graph writes its entire working state to disk on every single step. Our graph carried a 40KB document context through every hop; after 2,000 checkpointed runs, the checkpoints and checkpoint_writes tables grew past 1.2 GB. There's no built-in TTL, meaning no automatic expiry, and no retention policy. We added a nightly DELETE FROM checkpoints WHERE created_at < now() - interval '30 days' job plus a VACUUM; schedule this before you launch, not after.
3. Serializing channel state is a bottleneck. With a large state dict, our checkpoint write latency hit 110–180ms on the slowest 5% of writes on a local Postgres 16. That's pure overhead per super-step. On a graph that fans out into parallel branches, this overhead stacks up. Keep state lean; store large payloads by reference; a pointer into object storage; not inline.
4. No transactional boundary between side effect and checkpoint. The checkpointer is a separate database from your application data. Even in the same Postgres instance, the checkpoint write and your charge record are in separate transactions. There is no "commit node result and side effect atomically" primitive. You have to record the payment intent first, make the call, then mark it done.
5. setup() migrations run eagerly on every cold start. Minor, but in multi-replica deployments we saw transient lock contention when three pods called checkpointer.setup() simultaneously. Gate it behind a one-time init job or an advisory lock.
6. Interrupt/resume for human-in-the-loop is solid, but the same replay rule applies. interrupt() before a node works beautifully. But if you interrupt after a side-effecting node whose write landed, resuming will not re-fire it (that path is fine); if you interrupt during one, everything in point 1 applies.
Scale, Latency & Cost vs. Alternatives
Our benchmark: 50-thread concurrent fan-in graph, Postgres 16 on 4 vCPU, node execution dominated by a 300ms mock LLM call.
| Dimension | LangGraph + Postgres checkpointer | Temporal | Inngest | LangGraph Cloud |
|---|---|---|---|---|
| Checkpoint granularity | Super-step (node boundary) | Task/activity level, replay-safe | Step-level, replay-safe | Super-step, managed |
| Duplicates side effects on mid-node crash? | Yes, without app-level idempotency | No (deterministic replay) | No | Yes on long calls (issue #7417) |
| p95 checkpoint overhead | 110–180ms | Not measured in our lab | Not measured in our lab | Managed, opaque |
| Ops burden | Medium (you own Postgres) | High (Temporal cluster or Temporal Cloud) | Low | Low |
| Cost at 100K runs/mo | ~$40–70 (managed Postgres) | Not priced in our lab; see our Temporal/Inngest audit | Usage-based; not priced in our lab | Platform pricing, per-deployment |
| Human-in-the-loop interrupts | First-class, excellent | Requires workflow design | Supported | First-class |
Break-even analysis. For a team already running Postgres, LangGraph + Postgres checkpointer is the cheapest durable-execution option by far; but only if your tool calls are short (<10s) or you build idempotency shims. Once your graph contains multiple long, side-effecting calls and you need guaranteed exactly-once delivery, the cost of building replay-safe wrappers starts to rival Temporal. In that setup, LangGraph would serve only the graph structure. Our rough crossover: at fewer than ~5 side-effecting nodes per graph with <10s calls, LangGraph-native wins; beyond that, or with any payment-grade side effect, budget for either Temporal or a serious idempotency layer.
What This Article Could Not Verify
We did not test LangGraph Cloud directly; the Cloud-side behavior rests on open issue #7417 and may have changed since it was filed.
Our Final Verdict: When to Deploy, When to Skip
Deploy this if:
- Your graph nodes are short, read-heavy, or idempotent (retrieval, classification, summarization).
- You already run Postgres and want human-in-the-loop interrupts with minimal infrastructure.
- You can wrap every side-effecting call with a deterministic idempotency key before launch day.
Hold off or avoid if:
- Your agents fire paid, non-idempotent API calls (payments, trading, messaging) with call durations over ~30 seconds. Until issue #7417-class behavior is fixed and checkpoint replay becomes task-granular, you will eventually fire a duplicate.
- You need exactly-once semantics as a platform guarantee rather than an application-layer discipline.
- You're on LangGraph Cloud with long tool calls and no monitoring on the external side-effect ledger; the failure is silent.
LangGraph's checkpointing model is genuinely good engineering for what it claims to be: a state machine with persistence between steps, and strong human-in-the-loop support. The trap is assuming "durable execution" means what Temporal means by it. It doesn't. Treat it as at-least-once at node granularity: the system promises the work happens at least once, not that it happens exactly once. Design your side effects accordingly, and it's a solid production choice.
Full setup scripts and the duplicate-detection harness are linked from our tools collection, and if you want help hardening an agent stack before launch, talk to our team. We've also published a companion comparison against external orchestrators; see our durable-workflow audit for the Temporal/Inngest side of this trade-off.
Get the next one
in your inbox.
One short weekly dispatch with new guides, tools, and what we tested. No spam, unsubscribe anytime.
Get weekly AI tool reviews & automation tips
Join our newsletter. No spam, unsubscribe anytime.
More in Articles
We ran Qdrant's dense+sparse hybrid search with FastEmbed and RRF fusion end-to-end on a single Docker container, hit every documented failure mode, and turned the production checklist into fixes we actually applied.
Agent while-loops lose state on every crash, timeout, and rate limit. How durable execution with Inngest AgentKit or Temporal fixes it, with code blueprints.
Compare the top AI agent frameworks in 2026 — LangGraph, CrewAI, OpenAI Agents SDK, and more — with code examples and use-case recommendations.
We installed Google Antigravity 2.0 in our lab and put its Agent Manager and browser-driving agents through a scripted battery of real repo tasks, including a browser-driven E2E test. Here is what worked, what broke, and whether it justifies a seat.