A LangGraph graph is a set of nodes wired by edges, and by default it has no memory of a run once the process ends. So when an agent dies at the third node of a six-node graph — a tool timed out, the pod got recycled, someone deployed — the next invocation starts at node one, re-running everything it already did and re-paying for every model call. That's the default, not a bug. Turning it off takes two things: a checkpointer and a stable thread_id.
This is the copy-paste path: make a graph resumable, crash it on purpose, resume it, and move from an in-memory checkpointer to Postgres for production. If you're building on CrewAI instead, the exact same problem and its fix live in make your CrewAI Flow survive a crash with @persist — this is the LangGraph twin of that piece.
The takeaway, in one line#
Compile with a checkpointer, invoke with a stable thread_id, and a crashed graph resumes from its last node instead of its first. Everything below is the detail.
1. Compile with a checkpointer#
The checkpointer is the component that snapshots graph state after every node. You pass it at compile time. Start with MemorySaver — it keeps state in RAM, which is perfect for seeing the mechanism work:
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from typing import TypedDict
class State(TypedDict):
topic: str
outline: str
draft: str
def make_outline(state: State):
return {"outline": f"Outline for: {state['topic']}"}
def write_draft(state: State):
return {"draft": f"Draft from {state['outline']}"}
builder = StateGraph(State)
builder.add_node("make_outline", make_outline)
builder.add_node("write_draft", write_draft)
builder.add_edge(START, "make_outline")
builder.add_edge("make_outline", "write_draft")
builder.add_edge("write_draft", END)
graph = builder.compile(checkpointer=MemorySaver())
That single checkpointer= argument is the whole opt-in. After each node runs, its output state is checkpointed. Without it, the graph is amnesiac between invocations.
2. Invoke with a stable thread_id#
A checkpointer stores state per thread, and the thread is identified by thread_id, which lives in the config:
config = {"configurable": {"thread_id": "article-4821"}}
graph.invoke({"topic": "stateless MCP"}, config=config)
Here's the trap that costs people an afternoon: if you generate a fresh thread_id every run — a new UUID each time — every invocation is a brand-new thread with no history, and nothing ever resumes. The checkpointer is faithfully saving to a key you'll never look up again. To make resume work, pass a stable id tied to the unit of work: a job id, an order id, a document id. Re-invoke with the same thread_id and the graph rebuilds its state from the last checkpoint and continues from the node after the one that finished.
The checkpointer is one line at compile time. The engineering that decides whether resume actually works is choosing a thread_id you'll reuse — not a random one you'll never see again.
3. Prove it: crash, then resume#
Force a failure in the second node so you can watch resume work:
def write_draft(state: State):
if not state.get("draft"):
raise RuntimeError("boom — API timeout")
return {"draft": f"Draft from {state['outline']}"}
Invoke with thread_id="article-4821". The first node checkpoints its outline; the second raises. The outline is now saved against that thread. Fix the transient error (or remove the raise) and invoke the same graph with the same config again — LangGraph reconstructs state from the checkpoint, sees make_outline already completed, and resumes at write_draft. The outline node does not re-run.
Want to see what's stored? graph.get_state(config) returns the current StateSnapshot, and graph.get_state_history(config) returns every past checkpoint, newest first. That history is what powers time travel: pick an earlier checkpoint and resume from it to branch — the standard move for recovering from a bad tool call or letting a human redirect the run.
4. Ship it: MemorySaver → SqliteSaver → Postgres#
MemorySaver loses everything when the process exits, so it's for tests and demos, not recovery. The progression by environment is well-worn:
SqliteSaver(fromlanggraph-checkpoint-sqlite) writes to a local file, so state survives a restart. Good for local dev and single-process apps.PostgresSaver(fromlanggraph-checkpoint-postgres) is the production answer: multiple workers can read and write the same checkpoint store, so any API replica can handle any turn of the same thread — no sticky routing.
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:pass@host:5432/db"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # once: creates the checkpoint tables
graph = builder.compile(checkpointer=checkpointer)
graph.invoke({"topic": "stateless MCP"},
config={"configurable": {"thread_id": "article-4821"}})
Two production gotchas worth internalizing before they page you. Call .setup() once on first use so the tables exist. And keep thread_id values under 255 characters — Postgres stores the id in a length-limited column, and an over-long id raises a database error rather than truncating quietly. Pick short, meaningful ids.
The decision, made plainly#
- Add a checkpointer at compile time the moment a graph does anything expensive or long-running. It's one argument.
- Invoke with a stable, meaningful
thread_id. This is the difference between resume-works and resume-is-a-no-op. - Use
MemorySaverfor tests,SqliteSaverfor local dev,PostgresSaverfor anything with more than one worker. - Reach for
get_state_historywhen you need to inspect, rewind, or branch a run.
A multi-agent graph that can't survive a restart is a demo that hasn't failed yet. A checkpointer and a stable thread_id move it across that line — and if you're on the other major framework, the CrewAI @persist version of this exact playbook is a decorator away.



