Skip to content
Effloow
← Back to Articles
AI INFRASTRUCTURE ARTICLES ·2026-09-05 ·BY EFFLOOW EDITORIAL ·8 MIN READ

LangGraph in Production: Checkpointing, Postgres Persistence, and the Silent Re-Execution Trap We Hit Firsthand

We deployed LangGraph with a Postgres checkpointer in our lab, reproduced the silent re-execution trap on long tool calls, and documented the exact gotchas, workarounds, and cost math for production agent deployments.
langgraph durable-execution postgres agent-orchestration production
SHARE
Illustration for LangGraph in Production: Checkpointing, Postgres Persistence, and the Silent Re-Execution Trap We Hit Firsthand
Illustration: AI-assisted. Editorial policy

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.

The Silent Re-Execution Trap, Measured
Charge records after one kill + resume 2 rows
Lab tool call duration (charge_customer) 60 seconds/180
Cloud re-execution reproductions (issue #7417) ~180s+/180
Checkpoint tables after 2,000 runs 1.2 GB
p95 checkpoint write latency (slowest 5%) 110-180ms

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:

  1. Stand up LangGraph with a Postgres checkpointer in Docker, the way you'd actually run it in production.
  2. Build a graph with a deliberately long-running tool call (45–90 seconds) that has an observable, paid side effect.
  3. 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