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:

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#

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.