Persistence, Interrupts, Streaming, Time Travel¶
How LangGraph specifically implements each layer of memory, plus the four stream modes and time travel — all consequences of per-superstep checkpointing.
Rapid Recall
LangGraph splits memory into two distinct systems: Checkpointer = short-term, keyed by thread_id (one conversation). Store = long-term, keyed by namespace (e.g. user_id), cross-thread. Hierarchy: MemorySaver (dev) → SqliteSaver (local) → PostgresSaver (prod). HITL via interrupt(payload): the graph pauses, state is checkpointed, the call returns. Resume with Command(resume=value); the interrupt() call returns that value as if it had been waiting. Requires a checkpointer. Four stream modes: values (full state per step), updates (per-node diffs), messages (LLM tokens), custom. Time travel: get_state_history, fork from any checkpoint, update_state to edit and resume.
§11 — How LangGraph handles memory: checkpointers and the store¶
You know the theory of agent memory (working / short-term / long-term / episodic). Here's how LangGraph specifically implements each layer.
LangGraph splits memory into two distinct systems¶
| Memory type (theory) | LangGraph mechanism | Scope |
|---|---|---|
| Working memory | The state itself (the channels) | Current run |
| Short-term memory (this conversation) | Checkpointer, keyed by thread_id |
One thread (conversation) |
| Long-term memory (across conversations) | Store, keyed by namespace + key | Across all threads for a user |
| Episodic / semantic | Built on the Store (or external vector DB) | Cross-thread |
The crucial distinction LangGraph makes: checkpointer = short-term (per-thread), store = long-term (cross-thread). This trips people up. Let's separate them clearly.
Short-term memory: the checkpointer¶
The checkpointer persists the full graph state after every superstep, keyed by thread_id. This is what makes a conversation continuous.
from langgraph.checkpoint.memory import MemorySaver # dev
# from langgraph.checkpoint.postgres import PostgresSaver # production
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# Thread A — one conversation
config_a = {"configurable": {"thread_id": "conversation-A"}}
graph.invoke({"messages": [HumanMessage("Hi, I'm Sam")]}, config=config_a)
graph.invoke({"messages": [HumanMessage("What's my name?")]}, config=config_a)
# ↑ Second call REMEMBERS "Sam" because same thread_id loads the prior state.
# Thread B — a DIFFERENT conversation, fresh state
config_b = {"configurable": {"thread_id": "conversation-B"}}
graph.invoke({"messages": [HumanMessage("What's my name?")]}, config=config_b)
# ↑ No memory of Sam — different thread.
The mechanism (read-execute-write cycle):
- Retrieve, on invoke with a
thread_id, load the latest checkpoint for that thread. - Execute, run the graph, starting from that loaded state.
- Persist, after each superstep, write the new state as a new checkpoint.
The checkpointer keeps a history of checkpoints (not just the latest), which is what enables time travel.
The checkpointer hierarchy¶
| Checkpointer | Persistence | Use |
|---|---|---|
MemorySaver / InMemorySaver |
In-process RAM | Dev, tests, notebooks |
SqliteSaver |
Local SQLite file | Local apps, single machine |
PostgresSaver |
Postgres | Production |
| Redis checkpointer | Redis | High-throughput production |
The single most important production fact: MemorySaver loses everything on restart. For production you need PostgresSaver (or Redis).
Long-term memory: the Store¶
The checkpointer is per-thread. But you want some memory to persist across threads — user preferences, facts learned in past conversations. That's the Store.
from langgraph.store.memory import InMemoryStore # dev
# from langgraph.store.postgres import PostgresStore # production
store = InMemoryStore()
graph = builder.compile(checkpointer=checkpointer, store=store)
# Inside a node, access the store via the runtime:
def node(state, runtime):
store = runtime.store
user_id = runtime.context["user_id"]
# Namespaced by user → cross-thread, per-user memory
namespace = ("memories", user_id)
store.put(namespace, "food_preference", {"value": "vegetarian"})
memories = store.search(namespace) # retrieve this user's memories
return {...}
The Store is namespaced (a tuple like ("memories", user_id)) and stores key-value pairs. It supports semantic search if you configure it with an embedding model, turning it into vector-backed long-term memory.
The privacy point: namespace by user_id so retrieval is naturally scoped per user. The store enforces the namespace boundary.
The complete memory picture¶
flowchart TB
subgraph R[" One agent run "]
W[Working memory = State channels<br/>lives during the run]
end
R -.after every superstep.-> CK[CHECKPOINTER<br/>short-term per thread_id<br/>full state snapshots with history]
R -.app calls put/search.-> ST[STORE<br/>long-term per namespace<br/>vector-search if configured]
The clean mental model:
- State = what the agent is thinking about right now.
- Checkpointer = the memory of this conversation (resume where you left off).
- Store = the memory of this user across all conversations (remember their preferences).
Interview note
"How does LangGraph handle memory?" Two systems. The checkpointer persists full graph state per thread_id after every superstep, that's short-term/conversation memory and what enables resume + time travel; PostgresSaver in production. The store is namespaced key-value (by user_id, say) that persists across threads, that's long-term memory, optionally with embedding-based semantic search. State is working memory; checkpointer is this-conversation memory; store is across-conversation memory.
§12 — Human-in-the-loop: interrupt and resume¶
You know the HITL theory. Here's the LangGraph mechanism precisely.
The interrupt primitive¶
A node calls interrupt(payload). The graph pauses, persists its state to the checkpointer, and returns control to the caller with the payload. Later, the caller resumes by invoking the graph with a Command(resume=value), and the interrupt(...) call returns that value, as if it had been waiting.
from langgraph.types import interrupt, Command
def approval_node(state):
# Pause here. The payload is what your UI shows the human.
decision = interrupt({
"action": state["proposed_action"],
"question": "Approve this action?",
})
# When resumed with Command(resume="approve"), `decision` == "approve"
if decision == "approve":
return {"status": "approved"}
return {"status": "rejected"}
Why this is powerful¶
The pause is durable, not a held thread. When interrupt fires:
- State is checkpointed (so it survives a server restart).
invokereturns — your server is free, no connection held.- Hours or days later, the human clicks approve.
- You call
graph.invoke(Command(resume="approve"), config={thread_id}). - The graph reloads state, the
interrupt()call returns"approve", execution continues from exactly there.
interrupt requires a checkpointer, without persistence, there's nothing to resume from.
Two ways to set up interrupts¶
1. Dynamic interrupt (inside a node) — the modern way¶
Call interrupt() inside a node, as above. Flexible — you decide at runtime whether to pause, and the payload can include live state.
2. Static interrupt (at compile time)¶
graph = builder.compile(
checkpointer=checkpointer,
interrupt_before=["tools"], # pause BEFORE the tools node every time
# interrupt_after=["agent"], # or pause AFTER a node
)
Static interrupts pause at fixed points. Useful for "always review before executing tools." Less flexible than dynamic, but zero code in the node.
The four HITL patterns¶
| Pattern | LangGraph mechanism |
|---|---|
| Approve / reject | interrupt() returns the decision; node branches on it |
| Edit the proposed action | interrupt() returns the edited value; node uses it |
| Human handoff | interrupt() then route to a "human" node |
| Inject missing info | interrupt() asking for the field; resume with the value |
Inspecting the interrupt from outside¶
After an invoke that hits an interrupt, you inspect what it's waiting on:
state = graph.get_state(config)
if state.interrupts:
# state.interrupts[0].value is the payload you passed to interrupt()
show_to_user(state.interrupts[0].value)
Then resume:
Interview note
"How does human-in-the-loop work in LangGraph?" A node calls interrupt(payload); the graph checkpoints its state and returns control, no thread held, survives restarts. The UI shows the payload; the human decides; you resume with Command(resume=value), and the interrupt() call returns that value, continuing execution from exactly that point. Requires a checkpointer. Static interrupts (interrupt_before/interrupt_after at compile) pause at fixed nodes; dynamic interrupt() pauses conditionally with live payloads.
§13 — Streaming and time travel¶
Two more capabilities you get for free from the graph model.
Streaming: four modes¶
LangGraph can stream a running graph. You call graph.stream(...) (sync) or graph.astream(...) (async) instead of invoke, and iterate over the events. There are four stream_mode values:
stream_mode |
What it emits | Use for |
|---|---|---|
"values" |
The full state after each superstep | Watching the whole state evolve |
"updates" |
Only the state diff each node produced | Progress tracking (which node ran, what it changed) |
"messages" |
LLM tokens as they stream, plus metadata | Chat UIs, token-by-token output |
"custom" |
Whatever you emit via get_stream_writer() |
Custom progress events from inside nodes |
# Stream state updates (progress)
for chunk in graph.stream({"messages": [...]}, config, stream_mode="updates"):
print(chunk) # {"node_name": {state updates from that node}}
# Stream LLM tokens (chat UI)
for token, metadata in graph.stream({"messages": [...]}, config, stream_mode="messages"):
print(token.content, end="")
# Stream multiple modes at once
for mode, chunk in graph.stream({...}, config, stream_mode=["updates", "messages"]):
...
The "messages" mode is what powers token-by-token chat; "updates" is what powers "the agent is now searching the web" progress indicators.
Time travel: forking from any past checkpoint¶
Because the checkpointer keeps a history of checkpoints (not just the latest), you can:
- List the history, every state the graph passed through.
- Fork from any point, re-run from an earlier state with different input.
# List every checkpoint for this thread
history = list(graph.get_state_history(config))
for snapshot in history:
print(snapshot.config["configurable"]["checkpoint_id"], "→ next:", snapshot.next)
# Pick a past checkpoint and fork from it
past = history[2] # some earlier state
fork_config = past.config # this config points at that specific checkpoint
# Resume execution FROM that past state — maybe with a different input
graph.invoke(new_input, config=fork_config)
Time-travel checkpoint tree¶
flowchart TD
C0[checkpoint 0<br/>initial] --> C1[checkpoint 1<br/>after step 1]
C1 --> C2[checkpoint 2<br/>after step 2]
C2 --> C3[checkpoint 3<br/>after step 3]
C2 -.fork from c2.-> C2A[checkpoint 2a<br/>alternative path]
C2A --> C3A[checkpoint 3a<br/>alternative outcome]
Why time travel matters:
- Debugging, inspect the exact state at each step to find where things went wrong.
- What-if analysis, fork from step 5, try a different path, compare outcomes.
- Error recovery, a transient failure at step 7? Re-run from the step-6 checkpoint instead of from scratch.
- HITL correction, human edits the state at a past point; re-run from there.
Updating state manually (the other half of time travel)¶
You can also modify a checkpoint's state directly, then resume:
# Overwrite part of the state at the current checkpoint
graph.update_state(config, {"some_field": "corrected_value"})
# Now resume — the graph continues with the corrected state
graph.invoke(None, config=config)
update_state respects reducers (so updating a messages field appends via add_messages unless you specify otherwise). This is how a human "edits the agent's plan" mid-run.
The connection to everything else¶
Streaming and time travel both fall out of the same property: the runtime checkpoints state after every superstep, keeping history. Streaming = emit each checkpoint as it's made. Time travel = reload an old checkpoint. Both are free consequences of the BSP + checkpointer architecture. This is the payoff of understanding the engine, these features stop being magic.
Interview note
"How does streaming work in LangGraph, and what's time travel?" Streaming: call graph.stream() with a stream_mode — "values" (full state per step), "updates" (per-node diffs, for progress), "messages" (LLM tokens, for chat UIs), or "custom". Time travel: the checkpointer keeps a history of every superstep's state, so you can list it (get_state_history), fork from any past checkpoint, or edit a checkpoint (update_state) and resume. Both are consequences of per-superstep checkpointing.
Interview Questions¶
Q11: How does LangGraph checkpointing work, and why does it matter for long-running agents?
Every node execution writes a state snapshot to the checkpointer DB. If the server crashes mid-graph, the next invocation with the same thread_id resumes from the last checkpoint, no lost work. Matters for long-running tasks (research agents, multi-day approvals, background jobs), human-in-the-loop (pause can last hours), and debugging (time travel through past state).