← Back to Learning Hub

Checkpointers, Threads, and Durable Execution

LangGraphDurable ExecutionAdvanced19 min

By: Anacodic Team

TL;DR

A checkpointer writes the graph's state to storage after every super-step, keyed by a thread_id you choose. That one mechanism is the foundation for everything stateful in LangGraph: multi-turn conversation, resuming after a crash, human-in-the-loop pauses, and time travel are all just consequences of "the state is on disk and addressable". A thread_id is a conversation identifier you invent — not a session, not a user, and reusing one accidentally is how two customers end up sharing a chat history. The boundary to understand: a checkpointer persists one thread's working state, so it gives you durability within a conversation and nothing at all across conversations. Cross-thread facts need a Store, which is a different object with different semantics.


Simple explanation + analogy

Without a checkpointer, a compiled graph is a pure function: state in, state out, nothing remembered. Call it twice and the second call has no idea the first happened. That is fine for a one-shot extraction and useless for anything conversational.

A checkpointer makes it a saved game. After every super-step the runtime writes a save file. The save is tagged with a slot name — the thread_id — so you can have hundreds of independent playthroughs in the same storage without them touching each other. Quitting the game does not lose progress; you reload the slot and continue from the last save.

The analogy earns its keep on three points:

  • Saves are automatic and frequent. You do not call save(). Every super-step commits a checkpoint, so the granularity of recovery is "one step of the graph", not "one run".
  • The slot name is yours to choose, and choosing badly is a real bug. Two players sharing a slot overwrite each other. Two users sharing a thread_id see each other's messages.
  • Reloading is exact. You resume with the same state, at the same position in the topology — the runtime knows which node was next, not just what the variables were.

Where the analogy breaks: a saved game stores everything about the world. A checkpointer stores one thread's state and nothing global. Facts that should outlive the playthrough — a user's standing preferences — do not belong in a checkpoint.


Diagram

  NO CHECKPOINTER — a pure function
  ---------------------------------
   invoke(s0) ─▶ [a] ─▶ [b] ─▶ END ─▶ s2      (s2 returned, then forgotten)
   invoke(s0) ─▶ [a] ─▶ [b] ─▶ END ─▶ s2      (identical; no memory of call 1)


  WITH A CHECKPOINTER — state addressed by thread_id
  --------------------------------------------------
                    thread_id = "onboard-ana"        thread_id = "onboard-bo"
                    ┌───────────────────────┐        ┌───────────────────────┐
   super-step 0 ───▶│ ckpt#0  {steps: []}   │        │ ckpt#0  {steps: []}   │
   super-step 1 ───▶│ ckpt#1  next=(collect)│        │ ckpt#1  next=(collect)│
        ...         │  ⏸ paused here        │        │  ⏸ paused here        │
                    └───────────┬───────────┘        └───────────┬───────────┘
                                │                                │
              ══════════ PROCESS EXITS ══════════════════════════════════
                                │                                │
                    ┌───────────▼───────────┐        ┌───────────▼───────────┐
   new process ────▶│ ckpt#2  resumed       │        │ ckpt#2  resumed       │
                    │ ckpt#3  next=()  DONE │        │ ckpt#3  next=()  DONE │
                    └───────────────────────┘        └───────────────────────┘
                       ana@example.com                   bo@example.com
                              ▲                                 ▲
                              └── never see each other's state ──┘

  storage layout:   (thread_id, checkpoint_id) -> {values, next, metadata}

How it works (deep)

1. Compile with it, then always pass a thread_id

Two halves, and forgetting the second is the usual first error:

app = graph.compile(checkpointer=saver)
cfg = {"configurable": {"thread_id": "onboard-ana"}}
app.invoke(initial, cfg)

The checkpointer is bound at compile time; the thread is chosen per call. A compiled graph with a checkpointer but no thread_id in the config raises — the runtime has no idea which conversation you mean.

2. What a checkpoint actually contains

More than the variables. Each checkpoint is a snapshot with the position baked in:

FieldMeaning
valuesthe state dict after this super-step
nexttuple of nodes scheduled to run next — () means finished
configincludes checkpoint_id, the address of this exact snapshot
metadatastep number, source, and the writes that produced it
taskspending tasks, including interrupt payloads

next is what makes resumption exact rather than approximate. A save file that only had values would leave you guessing where to restart; next says precisely which node was about to run. That is also why get_state(cfg).next is the canonical "is this thread waiting on something?" check.

3. thread_id is an identifier you design

Nothing derives it for you. It is a string, and its structure is a schema decision you should make deliberately:

f"support-{ticket_id}"          # one thread per ticket
f"chat-{user_id}-{session_id}"  # one thread per user session
f"onboard-{user_id}"            # one long-lived thread per user

Three failure modes to design against. Collision: a thread_id of just user_id means every conversation that user ever has is one ever-growing thread — state balloons and old context leaks into new questions. Leakage: a thread_id derived from something client-supplied and unvalidated lets a caller read another user's thread by guessing it; treat it as an authorisation boundary and check ownership server-side. Unbounded growth: threads are never garbage-collected. A messages list with a reducer grows forever, checkpoint size grows with it, and nothing prunes it — you need a retention policy and, usually, trimming inside the graph.

4. Choosing a checkpointer

The interface is identical; the durability is not, and this is a production decision rather than a preference.

CheckpointerSurvivesConcurrencyUse for
InMemorySavernothing — dies with the processsingle processtests, notebooks
SqliteSaverprocess restartone host, careful writerslocal apps, single-node
PostgresSaverrestart, multi-hostmany workersproduction

SqliteSaver and PostgresSaver are context managers over a connection:

with SqliteSaver.from_conn_string("onboarding.db") as saver:
    app = graph.compile(checkpointer=saver)

Compiling inside the with block matters — the saver holds the connection, and using the compiled app after the block exits gives you a closed-connection error.

The trap that costs teams a week: prototype with InMemorySaver, write tests that pause and resume inside a single test function, ship, and discover every in-flight approval disappears on the first deploy. The tests pass because the process never ended. Test durability by ending the process, which is exactly what the runnable example below does.

5. Reading and writing state from outside the graph

snap = app.get_state(cfg)                    # latest checkpoint
snap.values, snap.next                       # state, and what runs next
list(app.get_state_history(cfg))             # every checkpoint, NEWEST FIRST
app.update_state(cfg, {"email": "x@y.com"})  # write a patch as if a node had

update_state commits a new checkpoint attributed to a synthetic step, which is how you correct a bad value or seed a thread before running it. It is powerful and easy to misuse: patches bypass your node logic and validation, so state can end up in a shape no node would ever have produced. Prefer resuming through the graph when you can.

6. Durable execution and what "resume" re-runs

Because a checkpoint lands after every super-step, a crash costs you at most the super-step in flight. Resuming with invoke(None, cfg) continues from the last committed checkpoint.

The granularity is the super-step, not the line. A node that crashed halfway through re-runs from its top; work it had already done in memory is gone, and any side effect it completed before crashing happens again. So durable execution gives you "at-least-once node execution", which means node bodies should be replay-safe: idempotent writes, external calls keyed on something stable. This is the same replay rule that makes interrupt() re-run its node, and it comes from the same place.

7. Where the checkpointer stops helping

It persists one thread's working state. It has no concept of a user, an organisation, or a fact that should apply to every future conversation. Writing "prefers aisle seats" into checkpointed state means it lives in that thread and is invisible to the next one. Cross-thread memory is a Store, covered separately — and conflating the two is the most common architectural mistake in LangGraph apps.


The math

The useful calculations are recovery cost and storage growth.

  k     = super-steps in a full run
  t_s   = wall-clock per super-step
  w     = checkpoint write cost per super-step

  run time, no checkpointer     T_0   = k * t_s
  run time, checkpointed        T_c   = k * (t_s + w)
  overhead                      rho   = w / t_s

  work lost to a crash
    no checkpointer             = everything completed so far
    checkpointed                = the in-flight super-step only, <= t_s

  storage for one thread        S_thread = sum over i of |state_i|
    with an append-only list of size growing by d per step:
      |state_i| ~ i*d  ->  S_thread ~ d * k^2 / 2      (QUADRATIC in k)

That last line is the one people are surprised by. Checkpoints are snapshots, not diffs, so a growing messages list is re-serialised in full at every step, and total storage for a thread is quadratic in its length.

Worked example

A 20-step agent, t_s = 1.5 s, checkpoint write w = 0.03 s, messages growing d = 2 KB per step.

  T_0   = 20 * 1.5                    = 30.0 s
  T_c   = 20 * (1.5 + 0.03)           = 30.6 s
  rho   = 0.03 / 1.5                  = 0.02      -> 2% slower

  crash at step 18:
    no checkpointer   lose 18 * 1.5   = 27.0 s of work
    checkpointed      lose <= 1.5 s   = 1.5 s      -> 18x less lost work

  storage for the thread:
    S = 2 KB * 20 * 21 / 2            = 420 KB   (not 20 * 2 KB = 40 KB)

Two percent overhead to cut crash losses by 18x is an easy trade. The 420 KB against a naive 40 KB estimate is the part that bites at scale: 100,000 threads of 20 steps is 42 GB, not 4 GB. Trim history inside the graph if threads are long-lived.


Real code

The honest durability test: two separate OS processes sharing one SQLite file. Process 1 starts two runs and exits with both unfinished; process 2 has nothing in memory and completes them from disk.

import sys
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.sqlite import SqliteSaver

class Onboard(TypedDict):
    user: str
    steps_done: list[str]
    email: str

def collect_email(state: Onboard) -> dict:
    answer = interrupt({"prompt": f'email for {state["user"]}?'})
    return {"email": answer, "steps_done": state["steps_done"] + ["collect_email"]}

def finish(state: Onboard) -> dict:
    return {"steps_done": state["steps_done"] + ["finish"]}

def build():
    g = StateGraph(Onboard)
    g.add_node("collect_email", collect_email)
    g.add_node("finish", finish)
    g.add_edge(START, "collect_email")
    g.add_edge("collect_email", "finish")
    g.add_edge("finish", END)
    return g

phase = sys.argv[1] if len(sys.argv) > 1 else "start"
DB    = sys.argv[2] if len(sys.argv) > 2 else "onboarding.db"

with SqliteSaver.from_conn_string(DB) as saver:
    app = build().compile(checkpointer=saver)

    if phase == "start":
        # Two users on two thread_ids: same graph, fully isolated state.
        for user in ("ana", "bo"):
            cfg = {"configurable": {"thread_id": f"onboard-{user}"}}
            app.invoke({"user": user, "steps_done": [], "email": ""}, cfg)
            print(f"  {user}: paused at {app.get_state(cfg).next}")
        print("process 1 exiting with both runs unfinished")

    else:
        # A BRAND NEW process. Nothing in memory; state comes back off disk.
        for user, email in (("ana", "ana@example.com"), ("bo", "bo@example.com")):
            cfg = {"configurable": {"thread_id": f"onboard-{user}"}}
            before = app.get_state(cfg)
            print(f"  {user}: recovered, next={before.next}, steps={before.values['steps_done']}")
            out = app.invoke(Command(resume=email), cfg)
            print(f"  {user}: finished {out['steps_done']} email={out['email']}")

        ana = app.get_state({"configurable": {"thread_id": "onboard-ana"}}).values
        bo  = app.get_state({"configurable": {"thread_id": "onboard-bo"}}).values
        assert ana["email"] == "ana@example.com" and bo["email"] == "bo@example.com"
        assert ana["user"] == "ana" and bo["user"] == "bo", "threads never leaked into each other"
        n = len(list(app.get_state_history({"configurable": {"thread_id": "onboard-ana"}})))
        print("checkpoints saved for ana:", n)
        print("OK: state survived a process boundary; threads stayed isolated")

Run it twice — the second command is a different PID with an empty heap:

$ python3 onboarding.py start  onboarding.db
  ana: paused at ('collect_email',)
  bo: paused at ('collect_email',)
process 1 exiting with both runs unfinished

$ python3 onboarding.py resume onboarding.db
  ana: recovered, next=('collect_email',), steps=[]
  ana: finished ['collect_email', 'finish'] email=ana@example.com
  bo: recovered, next=('collect_email',), steps=[]
  bo: finished ['collect_email', 'finish'] email=bo@example.com
checkpoints saved for ana: 4
OK: state survived a process boundary; threads stayed isolated

Two details worth reading closely. next=('collect_email',) recovered in a fresh process is the proof that position, not just data, was persisted — process 2 knew which node to enter without being told. And checkpoints saved for ana: 4 for a two-node graph shows the write frequency: an initial checkpoint, one per super-step, plus the one recording the resume. Checkpoints are cheap individually and numerous by design.

Swap SqliteSaver for InMemorySaver and process 2 finds nothing — which is precisely the test a same-process test can never perform.


Real-world example

A course-support pipeline grades submissions, and grading a scanned handwritten answer sheet costs an OCR pass plus several sampled LLM grading calls. Uncertain grades pause for instructor review.

The first production incident had nothing to do with grading quality. A routine deploy restarted the API during a grading batch, and every submission mid-flight restarted from OCR on the next attempt — re-paying the most expensive step for work that had already succeeded. The checkpointer was InMemorySaver, chosen months earlier when the graph was a prototype, and it had never been revisited because the test suite paused and resumed inside single test functions and passed happily.

Moving to a database-backed checkpointer fixed the restart cost, and then exposed the second problem. The thread_id had been the assignment id. Two students' submissions for the same assignment therefore shared a thread, and the second overwrote the first — worse, an instructor reviewing a paused grade could be shown the wrong student's work. The fix was f"grade-{assignment_id}-{submission_id}", and the lesson was that a thread_id is a schema you design, not an id you happen to have handy.

The third problem showed up only after months of traffic: threads for long-running course conversations were tens of megabytes, because the message list grew all term and every checkpoint re-serialised the whole thing. Nothing was wrong — checkpoints are snapshots and storage is quadratic in thread length, exactly as the arithmetic above says. It needed a trimming step in the graph and a retention policy on old threads, neither of which the framework will do for you.


Interview questions companies actually ask

1. What is a checkpointer and when does it write? [easy] It persists graph state to storage after every super-step, keyed by thread_id. It is what turns a compiled graph from a pure function into something with memory, and it is the shared foundation under multi-turn conversation, crash recovery, human-in-the-loop pauses, and time travel — those are all consequences of the state being durable and addressable.

2. What is a thread_id, and where does it come from? [medium] A string you choose to identify one conversation or run. Nothing generates it for you, and its structure is a design decision: too coarse (just user_id) and every conversation merges into one growing thread; derived from unvalidated client input and a caller can read someone else's thread by guessing. Treat it as an authorisation boundary and check ownership server-side.

3. What is in a checkpoint besides the state values? [medium] next (which nodes were scheduled), a checkpoint_id addressing that exact snapshot, metadata including the step number and the writes that produced it, and any pending tasks such as interrupt payloads. next is what makes resumption exact — without it you would know the variables but not the position.

4. How much work does a crash cost with and without a checkpointer? [medium] Without one, everything completed in that run. With one, only the super-step in flight, since the previous step's state is committed. That is why 2-3% write overhead is an easy trade: a crash at step 18 of 20 loses one step instead of eighteen.

5. Why is checkpoint storage quadratic in thread length? [hard] Checkpoints are full snapshots, not diffs. If state grows by d per step, step i serialises roughly i*d, and summing over k steps gives about d*k²/2. A 20-step thread growing 2 KB per step costs ~420 KB, not 40 KB. Long-lived threads need trimming inside the graph plus a retention policy, because nothing garbage-collects threads.

6. Why does a node re-run from the top after a crash rather than resuming mid-node? [hard] Recovery granularity is the super-step, so the last committed checkpoint is the boundary — a partially executed node has no saved position inside itself. That gives at-least-once node execution, so node bodies must be replay-safe: idempotent writes and external calls keyed on something stable. It is the same replay rule that makes interrupt() re-execute its node.

7. InMemorySaver versus SqliteSaver versus PostgresSaver? [easy] Same interface, different durability: in-memory dies with the process (tests only), SQLite survives restarts on one host, Postgres survives restarts across many workers. The classic failure is prototyping with in-memory and shipping it, because same-process tests that pause and resume never exercise a real restart.

8. What can a checkpointer not do for you? [hard] Anything cross-thread. It persists one thread's working state, so a fact written into checkpointed state is invisible to every other thread. Durable user-level knowledge needs a Store, which is namespaced independently of threads. Conflating the two — expecting checkpoints to give you long-term memory — is the most common architectural mistake in LangGraph apps.

9. What does update_state do, and why be careful with it? [medium] It commits a state patch as if a node had produced it, creating a new checkpoint — useful for correcting a bad value or seeding a thread. The risk is that it bypasses node logic and validation, so state can reach a shape no node would produce, and the resulting checkpoint is attributed to a synthetic step rather than real execution. Prefer resuming through the graph.


When to use / tradeoffs

Reach for a checkpointer when:

  • The graph is conversational and needs multi-turn memory within a thread.
  • Runs are long or expensive enough that losing progress to a crash matters.
  • You use interrupt() — it is required, not optional.
  • You want time travel, replay, or the ability to inspect state after the fact.

Do NOT use when:

SituationWhy it breaksUse instead
Stateless one-shot call (extract, classify)Write per super-step is pure overheadCompile with no checkpointer
You need cross-conversation factsCheckpoints are thread-scoped and invisible elsewhereA Store alongside the checkpointer
Production, with InMemorySaverIn-flight state dies on every deploySqliteSaver / PostgresSaver
Very long threads, no trimmingSnapshot storage is quadratic in lengthTrim history in-graph + retention policy
Multiple workers, with SqliteSaverSingle-file writer contentionPostgresSaver

Honest limits. The overhead figure is optimistic because w is treated as constant while it actually scales with state size — a thread carrying a large message list pays a rising write cost per step, so rho climbs as the conversation grows rather than staying at 2%. Durable execution is at-least-once, not exactly-once: the runtime guarantees you can resume, not that your side effects happened once, and that distinction is yours to handle with idempotent writes. Checkpointing also gives you no isolation guarantees between concurrent writers on the same thread — two requests racing on one thread_id can interleave, and nothing detects it, so serialise per thread at the application layer if that is possible in your system. Finally, persistence is not privacy: checkpoints contain whatever your state held, including anything a user typed, so a checkpoint table inherits every retention and deletion obligation attached to that data.


  • A checkpointer writes state after every super-step, addressed by (thread_id, checkpoint_id).
  • It is the single mechanism under multi-turn memory, crash recovery, interrupt(), and time travel.
  • A checkpoint stores values and next, which is why resumption restores position, not just data.
  • thread_id is an identifier you design — and an authorisation boundary you must enforce.
  • Recovery granularity is the super-step, so nodes re-run from the top: write replay-safe node bodies.
  • Storage is quadratic in thread length because checkpoints are snapshots, not diffs.
  • It gives durability within a thread and nothing across threads — that is a Store's job.

Related articles in this module (6-6):

Related elsewhere:

Sources: LangGraph docs: persistence · LangGraph docs: durable execution · LangGraph checkpointer reference · Verified against langgraph 1.2.8 / langgraph-checkpoint-sqlite 3.1.0; all output above is from an actual two-process run.