LangGraph checkpointers
LangGraph's checkpointer from scratch: what state persistence really costs you
A checkpointer is a dictionary with a write on every step. Building one takes twenty lines; the bill arrives as write amplification and a serialization format you now have to version.
· 9 min read
A checkpointer is what makes an agent resumable. Pass one in, give each conversation a thread id, and the run survives a crash, pauses for a human, and can be rewound to any earlier step. It is the feature that separates a demo from something you can put behind a queue.
The mechanism
Two methods do the real work: save a snapshot, load the newest one. A third gives you history, which is where time travel and forking come from.
interface Checkpoint<S> {
threadId: string;
step: number;
state: S;
createdAt: string;
}
interface Checkpointer<S> {
put(checkpoint: Checkpoint<S>): Promise<void>;
latest(threadId: string): Promise<Checkpoint<S> | undefined>;
history(threadId: string): Promise<Checkpoint<S>[]>;
}
class MemoryCheckpointer<S> implements Checkpointer<S> {
private readonly threads = new Map<string, Checkpoint<S>[]>();
async put(checkpoint: Checkpoint<S>) {
const list = this.threads.get(checkpoint.threadId) ?? [];
list.push(structuredClone(checkpoint)); // a reference here is a race
this.threads.set(checkpoint.threadId, list);
}
async latest(threadId: string) {
return this.threads.get(threadId)?.at(-1);
}
async history(threadId: string) {
return this.threads.get(threadId) ?? [];
}
}The clone matters more than it looks. Storing a reference to a state object that the next step mutates means every checkpoint in the thread silently becomes the same value, and you find out during an incident rather than in a test.
Wiring it into a loop
Resuming is reading the last checkpoint instead of the initial state. That is the entire resume feature.
async function runResumable<S>(
threadId: string,
initial: S,
step: (state: S) => Promise<{ state: S; done: boolean }>,
saver: Checkpointer<S>,
) {
const resumed = await saver.latest(threadId);
let state = resumed?.state ?? initial;
let n = resumed ? resumed.step + 1 : 0;
while (true) {
const result = await step(state);
state = result.state;
// The whole feature is this line, in the right place: after the step
// succeeded, before the next one starts.
await saver.put({ threadId, step: n++, state, createdAt: new Date().toISOString() });
if (result.done) return state;
}
}Put the write after the step and you replay a step on crash. Put it before and you skip one. Neither is universally correct: replaying a read is free, replaying a payment is not. This is the choice a framework makes for you by default, and it is worth knowing which one you accepted.
The bill
Write amplification. Agent state is usually the message list, and the message list only grows. Snapshotting all of it every step means step 20 writes twenty times more than step 1. Total bytes written grows with the square of the step count:
step messages bytes written this step total written
1 2 ~1 KB ~1 KB
5 10 ~5 KB ~15 KB
10 20 ~10 KB ~55 KB
20 40 ~20 KB ~210 KB
40 80 ~40 KB ~820 KBAt demo length nobody notices. At forty steps with verbose tool output, a single conversation has written close to a megabyte to your database to produce one answer. The fix is to snapshot a delta or to keep large payloads outside the state and store references, and both are decisions you have to make deliberately because the default is to snapshot everything.
You just invented a schema. Checkpoints are serialized state, which means the shape of your state is now a persisted format. Rename a field and every in-flight thread resumes into an object that no longer matches the code reading it. This is ordinary database migration work, arriving through a door nobody labelled "migration".
Threads are forever. Nothing in the mechanism deletes anything. A checkpointer pointed at Postgres with no retention policy is a table that grows for as long as the product is alive, holding full conversation transcripts, which is also a data-protection question and not only a storage one.
One thread, one writer. Two requests resuming the same thread id concurrently both read the same checkpoint and both write step n. The in-memory version above cannot even detect it. Any real implementation needs the step number to be part of a uniqueness constraint, so the second writer fails loudly instead of quietly winning.
What survives the next rename
Checkpointer implementations, their constructor signatures and the names of their storage backends have all moved more than once. The mechanism has not: snapshot after each step, key by thread, read the newest to resume, keep the history to fork.
Once you have written the twenty lines yourself, the framework version stops being magic and starts being a component with a cost model, which is the only footing from which you can decide whether you want it.
The same mechanism, with its tradeoffs
Every pattern page runs its code on the page, in TypeScript, Python and Rust.