Problem Description
I’m experiencing significant storage issues with the LangGraph MongoDB checkpointer. The checkpoints collection is growing far beyond expected size and consuming excessive storage.
Current Situation
- Thread IDs: 16,000 conversations
- Total Messages: 36,542 messages across all conversations
- Checkpoint Records: 282,758 records
- Storage Used: ~30 GB
- Average Checkpoints per Thread: ~17.7 checkpoints per conversation
- Average Checkpoints per Message: ~7.7 checkpoint records per message
This means instead of maintaining a reasonable number of checkpoints per conversation, the system is saving multiple checkpoints per conversation (and per message), leading to exponential growth.
Environment
Dependencies:
"@langchain/core": "^0.3.77"
"@langchain/langgraph": "^0.4.9"
"@langchain/langgraph-checkpoint-mongodb": "^0.1.1"
"@langchain/openai": "^0.6.16"
Database: Azure Cosmos DB for MongoDB (RU-based account)
Note: Using Cosmos DB RU account means storage costs scale with data size, making this 30GB checkpoint collection a significant cost concern beyond just performance.
Questions
-
Is this expected behavior? Should LangGraph be creating ~18 checkpoints per conversation on average?
-
Checkpoint Retention: Is there a built-in mechanism to limit the number of checkpoints per thread, or do I need to implement manual cleanup?
-
Best Practices: What’s the recommended approach for managing checkpoint growth in production environments with high conversation volume?
-
Configuration Options: Are there any configuration parameters in @langchain/langgraph-checkpoint-mongodb to control:
- Maximum checkpoints per thread
- Automatic cleanup of old checkpoints
- Checkpoint retention policies
-
Migration Concerns: If I need to clean up old checkpoints, will this break existing conversation threads or affect the ability to resume conversations?
What I’ve Considered
- Manual cleanup scripts to delete old checkpoints while preserving the latest N checkpoints per thread
- Moving to a different checkpointing strategy
Any guidance on the recommended approach would be greatly appreciated. Has anyone else encountered similar scaling issues with the MongoDB checkpointer?
Additional Context
This is for a production AI chat+code where users have iterative conversations with an AI agent to build full-stack applications. Each conversation can involve multiple turns, and we need to maintain conversation state but don’t necessarily need to keep every checkpoint indefinitely.
Thanks in advance for any help!
Hi @Nikfury
Is this expected?
Yes. LangGraph persists a checkpoint at every “super-step” of graph execution, not once per user message. In a typical agent graph, a single user turn can span several super-steps (e.g., input ingest, LLM/tool nodes, reducer/merge, finalization), so multiple checkpoints per message is expected. Additionally, each checkpoint can have multiple “writes” stored in a separate checkpoint_writes collection (interrupts, errors, scheduled tasks, per-channel writes), which increases “records per message.”
Is there built-in retention?
AFAIK, not in the MongoDB checkpointer. The implementation exposes put, putWrites, getTuple, list, and deleteThread, but does not include any max-per-thread, TTL, or pruning configuration. You’ll need to implement manual cleanup or wrap/extend the saver to add TTL fields.
Best practices to manage growth
- Trim/summarize conversation state to shrink each checkpoint payload.
Use reducers to delete old messages or summarize history so fewer/lighter messages are saved in channel_values each step.
- Periodic pruning job (recommended): keep the latest N checkpoints per thread and delete older ones from both
checkpoints and checkpoint_writes.
Checkpoints are ordered by checkpoint_id (UUIDv6) which is time-sortable. The MongoDB saver itself sorts by checkpoint_id descending to fetch latest, so you can safely prune older IDs.
- Optional TTL (Cosmos DB/MongoDB): viable only if a top-level Date field exists.
The current saver stores checkpoint.ts and metadata inside serialized blobs, so there’s no top-level Date field you can index for TTL out of the box. If you require TTL-based expiry, wrap/extend the saver to add an expiresAt (or createdAt) top-level field on both collections and create TTL indexes in Cosmos DB.
Configuration options in @langchain/langgraph-checkpoint-mongodb
There are no built-in options to cap per-thread checkpoints or auto-prune. The only deletion helper is deleteThread(threadId). Use a scheduled cleanup job or extend the saver for TTL/retention.
Migration concerns when deleting old checkpoints
-
Deleting old checkpoints does not break resuming from the latest checkpoint (that’s what getTuple returns by default). You will, however, lose the ability to “time-travel” or replay to older steps you delete.
-
To be safe, keep at least the last few checkpoints per thread (e.g., N=3–10), and always delete both from checkpoints and matching checkpoint_writes to keep data consistent. Avoid pruning when you have an active human-in-the-loop interrupt on a thread unless you retain the parent checkpoint for that interrupt.
Hi @pawel-twardziak , The explanation helps.But, I want to understand subtle differences between platform offering of TTL ( as explained here ) vs when TTL is implemented at saver via expiresAt or createdAt. Consider the following example:
- In the case of langgraph platform deployment the TTL is at thread level configured in
langgraph.json not for each individual check point right ?
This is because, a thread is first created with the TTL configuration ? Later when checkpoints are created for the thread they implicitly inherit the TTL from thread. This means a thread with TTL 30 days for a conversational app, will expire at the end of 30 days even if the conversation between an agent and user was active on 30th day ?
- Now consider a TTL implementation for a custom saver, say,
MyCustomSaver(ttl=30days, other params....)running in a different platform (non-langraph where langgraph.json couldn’t be defined). In this case, the agent doesn’t use the create_thread for example. Again taking the same conversation app where conversations between an agent and a user is happening everyday for 30 days, then after 30 days only day-1 conversation expires while the remaining 29 days is retained, right ? This is because each checkpoint entry get its own expiresAt (or createdAt ) value depending on which day it was created and therefore will have its own TTL
I think both are possible and valid ? The general question I have is consider various use cases given in the table below and the agent patterns explained here and the whole content here , what is the right balance to avoid exploding no.of checkpoints and supporting various cases ?
The table gives a hint that TTL for each checkpoint alone cannot solve all the problems as some use cases require storing many checkpoints. Similar storing many checkpoints is not an optimal solution if the usecase conversation continuation. Considering all this is the following a reasonable choice in deciding what to support ?
Given this a reasonable start could be ttl_seconds= 30×24×60×60 (30 days in seconds) and max_checkpoints=10 ? MyCustomSaver(ttl_seconds=30days, other params....)
UPDATE: Thinking about it - ttl_seconds could be applied at checkpoint level whereas max_checkpoints is always a thread level concept.
Platform TTL is thread-scoped; per-checkpoint expiry now lives in the Mongo saver itself — no custom saver needed.
Your distinction is correct: langgraph.json TTL expires whole threads, while a per-checkpoint TTL ages out individual checkpoints. The piece worth updating is that the saver ships this natively as of 1.4.0.
Go deeper
Platform TTL (langgraph.json → checkpointer.ttl) is stamped at thread creation (not retroactive), with two strategies:
"delete" — drops the entire thread (runs + checkpoints) at expiry, so a 30-day thread dies at day 30 even if active.
"keep_latest" — keeps the thread + latest checkpoint, prunes the rest.
You can also set it per-thread via client.threads.create(ttl=...). Docs: Add TTLs.
For the per-checkpoint ttl_seconds you described, @langchain/langgraph-checkpoint-mongodb added a ttl option in 1.4.0 (enableTimestamps in 1.3.0):
const saver = new MongoDBSaver({ client, ttl: 30 * 24 * 60 * 60 }); // 30 days
await saver.setup(); // creates a Mongo TTL index on `upserted_at`
Each super-step writes a new doc with its own upserted_at, so stale checkpoints expire while active threads keep living.
max_checkpoints (keep-last-N) is inherently thread-scoped and has no built-in knob — it needs a small pruning job (delete from both checkpoints and checkpoint_writes; checkpoint_id is UUIDv6, so time-sortable). keep_latest is the closest managed equivalent, but keeps 1, not N.
TL;DR: ttl_seconds → the saver’s built-in ttl (per-checkpoint, via Mongo TTL index). max_checkpoints → pruning job (thread-level), as you said.
Thanks @dariel.datoon . My bad I should have explained a bit more. I was looking for a general guidance and best practices while implementing a custom checkpoint, particularly to manage growing checkpoints. For my scenario, I do not use mongo saver nor our backend is mongodb. However, while looking for guidance on managing TTL with customer saver I came across this post and found the explanation from @pawel-twardziak very useful.
To summarise my understanding :
- A TTL at saver is good strategy - To manage the checkpoints and therefore a 30-day thread if active on 30th day doesn’t get deleted after 30 days.
- However, another challenge which cannot be solved with TTL alone is the high number of most recent checkpoints that are within the TTL window that are not really useful. As having several checkpoints could impact query time and increase the storage foot print we could introduce another strategy - like max_checkpoints. From earlier responses I learnt that a reasonable number seems like N 3-10 (perhaps err on higher side). This should provide sufficient buffer for usecases like HITL, replay, history, etc .
Having only max_checkpoint N is also problematic as the checkpoints live forever. So the ideal strategy seems to be using them together - TTL to prevent eternal checkpoints / threads that are in-active and max checkpoints to avoid accumulating stale checkpoints.
From an implementation standpoint for non-platform situations, TTL can be configured at the saver. From my earlier example, MyCustomSaver(ttl_seconds=36000, <other-parmas>)
But, max_checkpoint should not be used at the saver level as any configuration defined at the saver is generally applicable for each individual checkpoint level. As you suggested a prune job that runs nightly in the background seems a viable solution. However, the other issue is how to expose this as a configurable parameter at agent level in non-platform setup.
Thanks once again for your thoughts!
Yep, that’s the right way to think about it — TTL to bound age, keep-last-N to bound count, and the two are complementary.
One thing worth knowing if you go the max_checkpoints route: BaseCheckpointSaver only exposes delete_thread (and delete_for_runs) — there’s no per-checkpoint delete in the interface. So keep-last-N always comes down to a backend-native query, which is exactly why your nightly prune-job instinct is the cleaner fit rather than baking it into the saver. Keep N on the higher end of that 3–10 range and your HITL/replay/history cases stay safe.
Glad the thread was useful — good luck with it!