← all posts
// agents · langgraph

Checkpointers: the feature that made LangGraph production-real for me

I was slow to take checkpointers seriously. Persistence sounded like a checkbox feature, the kind of thing you nod at on a slide and never configure. Then came a Tuesday in mid-May, and a deploy that should have ruined my afternoon.

Context: the support triage graph I've written about before (around 900 tickets a weekday, five nodes, an escalation path that can park a ticket with a human for days). It compiles with a checkpointer, meaning every super-step writes the graph state to a database before moving on. Thread by thread, step by step, boring by design.

sqlite on my laptop, postgres everywhere else

Local development runs SqliteSaver against a file in the repo, on my M2 MacBook Pro. Zero setup, survives dev-server restarts, and when something looks off I open the file with the sqlite3 CLI and read the checkpoints directly. For a while I believed that was all anyone needed.

I know because I shipped sqlite to production. One VM, one file on the data volume. Fine, right up until we went from one uvicorn worker to three and concurrent writes started failing with database-is-locked errors. Roughly one ticket in forty died at peak on the worst day. Moving to PostgresSaver took an afternoon, most of it on connection pooling, and the locking errors went to zero. Sqlite checkpointing is a development convenience, not a deployment strategy, whatever a single-process demo suggests.

thread_id is the identity model

The decision that shaped everything else: what is a thread? The thread_id is the key all checkpoints hang off, so choosing it means deciding what identity means in your app. We use the ticket number: ticket 48211 is thread ticket-48211 forever, every step it ever took retrievable by one key. Support tickets map cleanly onto threads. Not everything does, and picking wrong means fighting your own persistence layer for months.

saver = PostgresSaver.from_conn_string(PG_URL)
graph = builder.compile(checkpointer=saver)

cfg = {"configurable": {"thread_id": "ticket-48211"}}
graph.invoke(None, cfg)   # None input = resume from last checkpoint

That None-input resume is how it behaved in the builds I ran this spring; check the current docs before you copy it.

the tuesday that sold me

Mid-May, around 2 pm, we rolled a routine deploy. Kubernetes rotated the pods and, because I'd mistimed the rollout window (my fault entirely), killed workers holding 14 in-flight triage runs mid-graph. Old me starts drafting the apology and the re-run script.

Instead, the new pods came up, a startup job listed threads with pending work and invoked each with None input, and all 14 resumed from their last completed node. The ticket mid-draft re-drafted. The ticket parked on escalation stayed parked, untouched. Total damage: one duplicated classification step and about 90 seconds of extra latency.

That was the moment durable execution stopped being a conference word and became something I'd personally felt.

A checkpointer buys you at-least-once execution, not exactly-once magic — a node that sends email will cheerfully send it twice unless you make it idempotent.

That fine print bit us once. A crash landed after our notify node called Slack but before the checkpoint committed, so the resume re-ran the node and one customer got identical replies eleven minutes apart. Every side-effecting node now checks an idempotency key first. Checkpointing moved my failure model from losing work to repeating work. Repeating work is only safe if you design for it, which is most of what resilient AI architecture comes down to.

the ops bill nobody mentions

Checkpoints accumulate. Six or seven writes per run, 900 runs a day, state carrying a message list. Six weeks in, our checkpoint tables sat a bit over 7 GB, and the 85% disk alert introduced itself on a Sunday morning. Nothing prunes for you. Fair enough, but nobody tells you that on the way in either.

We settled on a nightly job: threads closed for more than 30 days keep only their final checkpoint for audit, the rest gets deleted, vacuum after. Storage dropped by about two thirds and has stayed flat. Keeping the state itself small helps more than any cleanup: that's the state-design story.

replay is the debugger I didn't expect

get_state_history returns every checkpoint a thread ever wrote, and you can re-run from any of them. When a weird misroute turned up on a Friday, I pulled the thread's history, found the checkpoint just before the wrong turn, patched one field, and re-invoked from there against the real data. Fifteen minutes, no synthetic repro. There's a whole time-travel discipline built on this that deserves its own write-up.

The costs are real: 9 to 20 ms of write latency per step depending on state size, a postgres to run, a pruning job to own. I pay it gladly on anything long-running. This is the feature that moved LangGraph from interesting to production-real for me.

#langgraph#persistence#reliability