Four checkpoints. That is what a two node graph writes for a single user turn, and I did not believe it until I printed the state history myself. Each of those rows is a serialised copy of your whole state object, so by turn forty of a support conversation you are writing megabytes of database traffic to keep track of a chat.
Three places state can live
Most confusion about memory in LLM applications comes from collapsing three separate things into one word. Separate them and the design decisions become obvious.
Request scoped state lives for one turn. Retrieved chunks, tool results, a scratchpad the model wrote to itself: none of it needs to survive the response, and keeping it costs you tokens on every subsequent turn. Thread scoped state lives for one conversation. Message history belongs here, as does anything a follow up question might refer back to, which is why and what about the second one resolves at all. Cross thread state lives for as long as the user does. A stated preference, an entitlement, a team a person belongs to: facts you want on turn one of a conversation that has not started yet.
Getting a fact into the wrong layer is not a style error, it is a bill. Anything you put in thread state is replayed into the prompt on every turn for the life of that conversation. Keep this table where you can find it.
| What you are storing | Layer | Lifetime | Where it goes |
|---|---|---|---|
| Retrieved doc chunks for this question | Request | One turn | Local variable, never persisted |
| Raw tool call results | Request | One turn, summary may persist | Dropped after the model reads them |
| Message history | Thread | One conversation, capped | Checkpointer or your own messages table |
| Ticket id under discussion | Thread | One conversation | Named state channel, not free text |
| Approval already granted this run | Thread | Until the run ends | Boolean channel, checkpointed |
| User prefers CLI over console answers | Cross thread | Until revoked | Store, namespaced by user |
| Which product tier this account is on | Cross thread | Owned elsewhere | Your existing database, fetched per turn |
State layer lookup. Last row matters more than it looks: plenty of what people call agent memory is just a database read they already had.
Threads and checkpoints, concretely
Last part the documentation assistant got a standard tool interface, so any team could call ticket lookup without reimplementing it. Where the project stands now: it retrieves over product docs, runs a bounded tool loop, and forgets the entire exchange the moment the response is written. This part gives it a thread, so a follow up like and who owns that one has something to resolve against.
A thread is an id you attach to a sequence of runs. A checkpoint is a snapshot of your state written at each super step, which is one tick of the graph where every scheduled node runs. Both concepts are worth understanding independently of any framework, because you will reimplement them badly if you do not.
Read that output carefully, because it is the whole mechanism. Newest checkpoint first. Step counter starts at minus one for the input write. Field next tells you which nodes were pending when the snapshot was taken, and an empty tuple means the run finished. Four rows for two nodes, exactly as the persistence documentation describes, and that ratio is what makes storage growth sneak up on people.
Resuming a workflow that crashed
Crash recovery is the honest reason to run a checkpointer, and it is a better reason than memory. If a node fails halfway through a super step, writes from nodes that already succeeded are stored as pending writes, so a resume does not re-run them. For a workflow where one node calls a model at two cents a shot and the next node posts to a ticketing API, not re-running is the difference between a retry and an incident.
That third line is a trap worth walking into once. Replaying from a checkpoint re-runs everything after it, and because steps has an accumulating reducer, answered appears twice. Replay is not undo. If your node has a side effect, replaying it fires that side effect again, so any node that posts, charges or emails needs an idempotency key held in state rather than a hope that it will not be replayed.
How hard you write is a separate knob. Three durability modes trade latency against how much you lose on a process kill, and the defaults are more sensible than the instinct most teams bring to them.
| Mode | When it writes | Added latency per super step | Use it when |
|---|---|---|---|
exit | Only when the run ends or interrupts | Roughly 0ms mid run | Short chat turns you would just retry anyway |
async (default) | While the next step runs | About 3ms on my Postgres instance | Almost everything, including agent loops |
sync | Before the next step starts | About 18ms on the same instance | Nodes with expensive or irreversible side effects |
Durability modes, measured on a four node graph against a single region Postgres. Your numbers will differ; the ratio held across three runs for me.
Memory that outlives a thread
Anything you want available on turn one of a conversation that has not happened yet belongs in a store, keyed by a namespace tuple rather than a thread. A store is a key value collection with optional semantic search over the values, which means it can be a small vector index if you want it to be. Concepts for that are in the GenAI Series piece on vector databases, and the practical comparison of backends is back in Part 11.
Two sharp edges here that cost me time. Namespace matching is by prefix, not exact, so searching ('u-8871',) also returns everything under ('u-8871', 'drafts') and any other sub namespace you created. And results past limit are truncated silently, with no overflow signal at all, so a user with forty stored facts quietly gets ten of them and you never see an error. Set limit above your expected maximum or paginate with offset.
InMemorySaver and InMemoryStore hold everything in process RAM and lose it on restart, which makes them fine for tests and wrong for anything a user touches. Move to PostgresSaver and a Postgres backed store before you ship, and remember that ordering differs between backends: Postgres returns items by updated_at descending, in memory returns insertion order. Sort client side if order matters to you.One clause on vendor differences, because this series stays neutral. Providers now offer server side state of their own: OpenAI lets you chain turns with previous_response_id or attach them to a Conversation object, with plain responses retained for 30 days by default and conversation items exempt from that expiry, and store: false to opt out entirely. Convenient, and I still keep history on my side. Server side threads are a retention policy and a data residency question you do not control, and they make switching provider a migration rather than a config change, which is the subject of Part 29.
Cost of an uncapped thread
Our documentation assistant used the staff member employee id as thread_id. Tidy, I thought: one thread per person, cross session memory for free. Six weeks later a support lead asked why the assistant had answered an SSO question with details from a billing incident. I pulled her thread. 312 messages, 47,000 tokens replayed into every single request, and cost per turn had drifted from about 0.011 dollars to 0.068 dollars without anyone flinching, because the absolute number stayed small next to the ingestion pipeline. Cost was not the real damage. Answer accuracy on my regression set had fallen from 0.89 to 0.71, because the model was weighing three weeks of unrelated conversation against four freshly retrieved chunks. Fix took an afternoon: thread_id became the conversation id, employee id moved into the store namespace, and a compaction node capped thread tokens near 5,000. p95 tokens per request settled at 4,800 and accuracy came back to 0.89.
You cannot manage this without measuring it, and measuring it is four lines. Run this against your longest lived thread before you decide whether compaction is urgent.
Storage grows on the same curve and is easier to ignore. Checkpoints write the full value of every state channel at each super step by default, which for my assistant averaged 2.1 KB a step and reached 340 MB of checkpoint rows across six weeks of modest traffic. If most of that growth is one append heavy channel, DeltaChannel stores incremental deltas instead of the accumulated value, though it needs langgraph 1.2 or newer and is still beta, so read the tradeoff before you adopt it. Cheaper answer for most teams: delete threads on a schedule. Nobody has resumed a support conversation from March.
Errors that show up once state is real
First one arrives within minutes, usually from a test that forgot its config. Compile a graph with a checkpointer, invoke it without a thread, and you get this rather than a silent fallback, which is the correct behaviour and still catches everyone once.
Second one is nastier because it only fires for some inputs. Default serialiser uses ormsgpack and JSON underneath, and it covers LangChain primitives, datetimes and enums, but not arbitrary Python objects. Put a pandas DataFrame of retrieval scores into state, as I did while debugging, and serialisation fails on the checkpoint write rather than where you created it, which sends you looking in the wrong file for twenty minutes. Two fixes, in order of preference: stop putting non serialisable objects in state and keep a list of dicts instead, or pass JsonPlusSerializer(pickle_fallback=True) as the serde argument to your saver and accept that pickle in your database is a decision you will have to defend later.
Third one is not an exception at all, which is why it is the expensive one. A thread whose id collides across users returns another person data with no error anywhere. Treat thread_id as a security boundary. Derive it server side from an authenticated session, never accept it from a client, and never build it out of anything a user can type. Input trust is the subject of Part 24, and this is an early instance of the same lesson.
Keep your own messages table and checkpoint only what you cannot recompute
Here is my actual position after running this in production, and it is less fashionable than it should be. Most teams building a chat style assistant do not need a graph framework checkpointer. A messages table keyed by conversation id, with your own append logic, is easier to query, easier to migrate, easier to redact for a deletion request and far easier to explain to whoever joins the team in March. Reach for a checkpointer when you have a genuinely multi step workflow with expensive or irreversible nodes, human approval gates that pause mid run, or a real need to replay history for debugging. That is a smaller set of applications than the tooling implies.
What I would avoid outright: keying threads by user id, running InMemorySaver anywhere a real person can reach, and letting a thread grow without a token ceiling. Each of those three cost me something measurable, and the third cost me eighteen points of accuracy before I noticed.
get_state_history for your longest running thread and count the tokens on the newest checkpoint. Above roughly 8,000 you have a compaction problem you have not priced yet, and you can confirm it in an afternoon by diffing cost per turn between your newest and oldest threads.Your assistant now remembers a conversation and knows a few durable things about the person asking. Next part looks at what happens when you point more than one agent at the same problem, and why coordination is usually more expensive than the work being coordinated. Structuring all of this so a colleague can run it is covered in the Data Science Series piece on going from notebook to package.
References
- LangGraph persistence: threads, checkpoints, stores and durability modes
- langgraph.checkpoint reference, BaseCheckpointSaver and serialisers
- OpenAI conversation state, previous_response_id and the store parameter


DrJha