Why a LangGraph agent's memory grows under default async durability mode
i run a handful of long-lived LangGraph agents from Singapore, and the pattern is always the same. the process starts at a sane resident size, behaves for a few hours, and then the RSS line on the dashboard tilts upward and never comes back down. no crash, no exception, just a slow climb until the container gets OOM-killed or someone restarts it. the question that keeps coming up in the community is the one in langgraph issue 7094: does the default async durability mode cause this, and if so, what exactly is accumulating?
i want to be straight about what this article is. i could not treat the issue thread as a verified spec while writing, so i am not going to quote it or claim what the maintainers concluded. what i can do is lay out the mechanism that makes the growth possible, show you how to confirm or rule it out in your own process with standard tooling, and be precise about what you give up when you move to another durability mode. every number in the worked examples is arithmetic on stated assumptions, not a benchmark i ran. where behaviour depends on your langgraph version, i say “as of October 2026” and you should check your pinned version.
the stakes are practical. an agent that leaks slowly looks healthy in a short test and dies in production on a Saturday night. worse, the usual fix people reach for (restart on a schedule) interacts badly with checkpointing, because a restart during a backlog of unflushed checkpoint writes is exactly when you lose the state you were paying to persist. so the memory question and the durability question are the same question.
Background and prior art
LangGraph persists graph state through checkpointers. after each superstep, the runtime can write a checkpoint (channel values, versions, metadata) to a saver such as the in-memory saver, SQLite, or Postgres. this is what makes resume, time travel, and human-in-the-loop interrupts possible. the LangGraph persistence docs describe the checkpoint model, and the durable execution docs describe the three durability modes you can choose per invocation: exit, async, and sync. as of October 2026 the documented defaults are that async is the mode used when you do not pass anything.
the one-line summary of the three modes, as i understand the docs:
- exit: state is persisted only when the graph exits (finishes, errors, or interrupts). fastest during the run, weakest recovery if the process dies mid-run.
- async: checkpoint writes are scheduled while the next step runs. good throughput, small window where the latest checkpoint is not yet durable.
- sync: each checkpoint write completes before the next step starts. strongest guarantee, slowest.
the prior art for the memory problem is not specific to LangGraph at all. it is the old producer/consumer mismatch: a producer that never waits, a consumer with finite throughput, and an unbounded buffer in between. in Python’s asyncio the buffer is the set of live tasks and the coroutine frames they hold. the asyncio task documentation is explicit that the event loop keeps only weak references to tasks and that you should hold references yourself, which is the flip side of the problem: whatever does hold references to pending work keeps all of that work’s data alive. when i say “coroutine chain” below, i mean tasks that each hold a reference to state and to the previous task.
The core mechanism
here is the model, in plain terms. treat it as my reading of how async durability can leak, not as a transcript of the langgraph source.
in a graph run, each superstep produces new channel values. under async durability the runtime does not block the next superstep on the checkpoint write for the previous one. instead it schedules the write as background work and moves on. that is the whole point of the mode: the graph’s critical path no longer includes a database round trip.
now consider what the scheduled write has to hold in memory until it completes:
- a checkpoint object: a copy or snapshot of the channel values at that superstep. for an agent, that includes the full message history, which grows each turn.
- the pending writes for that step (tool results, intermediate outputs).
- the coroutine frame itself, with its local variables, awaiting the saver’s I/O.
- if writes are ordered (and for a checkpoint chain they must be, since each checkpoint references its parent), a dependency on the previous write finishing first.
that last point is where the chain forms. if write N must wait for write N-1 so the parent pointer is valid, then each pending task is awaiting another pending task. as long as the saver keeps up, the chain is length one or two and everything is freed almost immediately. if the saver falls behind, the chain grows, and every link pins its own snapshot.
the key property is that snapshot size is not constant. an agent’s state includes accumulated messages. so if superstep k holds a snapshot of size s(k), and s grows roughly linearly with k, then a backlog of b pending writes costs approximately the sum of s over the backlog window, not b times a fixed number. a backlog that builds up late in a long run is the most expensive one, because it is made of the biggest snapshots.
when does the saver fall behind
the saver falls behind whenever the graph produces checkpoints faster than the backend acknowledges them. concretely:
- a fast graph (cheap nodes, no LLM call in some loops) against a remote Postgres with 5 to 20 ms of round-trip latency
- a connection pool that is smaller than the number of concurrent runs, so writes queue on pool acquisition
- a saver doing serialization on the event loop thread, so large states block the loop and starve other writes
- a database that is momentarily slow (vacuum, failover, a noisy neighbour), during which the producer keeps going
in a typical LLM agent, nodes are slow (hundreds of milliseconds to seconds per model call), so the saver usually keeps up and you never see the pile. the leak shows up in the shapes where the graph is fast and the loop is long: tool-heavy agents with local tools, retry loops, supervisor graphs that ping-pong between cheap routing nodes, and anything that runs thousands of supersteps in one invocation under a high recursion limit.
why it looks like a leak and not a spike
a backlog that eventually drains is not a leak. what makes this nasty is that there are two ways the memory stays high after the pressure ends.
first, python’s allocator. freed objects return to pymalloc arenas, not necessarily to the OS, so RSS can stay high even when the live object count has dropped. this is not LangGraph’s fault and it is not specific to async durability, but it makes a drained backlog look like a permanent leak on the dashboard.
second, anything that retains references past completion. if a done task, a future, or a callback list stays reachable from a long-lived object (a run handle, a stream, a tracer, a per-thread cache), then the snapshots it closed over stay alive too. i would not claim this happens in a given langgraph release without checking, but it is the first thing to test, and the tooling below does that.
a model you can run without langgraph
this is a plain asyncio sketch of the shape of the problem. it is not LangGraph code. it shows how an ordered, fire-and-forget write path accumulates snapshots when the sink is slower than the source.
import asyncio
import tracemalloc
async def slow_saver(snapshot: bytes, prev: asyncio.Task | None):
if prev is not None:
await prev # parent checkpoint must land first
await asyncio.sleep(0.02) # 20 ms backend write
async def run(steps: int, step_cost_s: float, async_mode: bool):
tracemalloc.start()
state = bytearray()
prev = None
for k in range(steps):
await asyncio.sleep(step_cost_s) # node work
state += b"x" * 2048 # state grows ~2 KB per step
snap = bytes(state) # snapshot pins its own copy
t = asyncio.create_task(slow_saver(snap, prev))
if async_mode:
prev = t # do not wait, chain grows
else:
await t # sync: wait before next step
prev = None
if prev:
await prev
cur, peak = tracemalloc.get_traced_memory()
print(f"async={async_mode} peak={peak/1e6:.1f} MB")
asyncio.run(run(steps=2000, step_cost_s=0.001, async_mode=True))
asyncio.run(run(steps=2000, step_cost_s=0.001, async_mode=False))
run it and compare the two peaks on your own machine. i am deliberately not printing numbers here, because they would be mine and not yours, and the absolute values depend on your interpreter and allocator. what you should see is that the async variant’s peak scales with the number of steps (the snapshots queue behind a 20 ms sink that the 1 ms producer outruns), while the sync variant stays flat. the tracemalloc documentation covers the snapshot and comparison APIs you will want for the real thing.
Worked examples
these are arithmetic examples on stated assumptions. none of them are measurements.
example 1: the fast supervisor loop
assume a supervisor graph that routes between two cheap nodes with no model call, running 3,000 supersteps in one invocation. assume state starts at 20 KB and grows by 2 KB per step from appended messages, and assume a Postgres saver at 15 ms per checkpoint write, serialized by the parent dependency.
- production rate: if each cheap node takes about 1 ms, that is roughly 1,000 checkpoints per second offered
- drain rate: at 15 ms per write, roughly 66 writes per second
- backlog growth: roughly 930 pending writes per second of run time
the graph would finish 3,000 steps in about 3 seconds, by which time about 2,800 writes are still pending. the snapshots in that backlog average around 3 MB (state at midpoint: 20 KB + 1,500 x 2 KB = about 3 MB), so the pinned memory is on the order of 2,800 x 3 MB, which is several gigabytes per invocation before the saver drains. even if my per-snapshot assumption is generous because the runtime shares structure between snapshots (it may well), the shape holds: the backlog is bounded by run length, not by a configured limit. under sync the same run takes about 3,000 x 15 ms = 45 seconds instead of 3, and the pinned memory is one snapshot.
that is the trade in one example. you traded 42 seconds of wall clock for a memory ceiling.
example 2: the long-running conversational agent
assume a customer-support agent, one thread per user, each turn = 3 supersteps, with model calls at about 1.5 s per node. the producer rate is roughly 0.7 checkpoints per second per active run. a Postgres saver at 15 ms keeps up easily, with a drain capacity of about 66 per second.
in this shape async durability should not accumulate anything on its own. but multiply by concurrency: with 200 concurrent runs sharing a pool of 10 connections, the aggregate offered rate is about 140 per second against a pool that can do at most 10 / 0.015 = about 660 per second in the ideal case. fine. now shrink the pool to 2 connections: capacity is about 133 per second, and a bit of jitter pushes you over. the backlog now lives in the pool’s wait queue and in the pending tasks, spread across 200 runs, each holding a conversation-sized snapshot (say 200 KB after 50 turns).
the lesson is that the leak can be a pool-sizing problem wearing a durability costume. if memory grows only under load and flattens when traffic drops, look at the pool first.
example 3: the retry storm during a database blip
assume the same support agent, normal operation, and a 30 second Postgres failover. for those 30 seconds every write stalls. under async, the graphs keep running (they are not waiting on the saver), so each of the 200 runs keeps producing checkpoints, each pinning its snapshot. suppose the 200 runs each add 10 steps in the window at 200 KB: 200 x 10 x 200 KB = 400 MB of pinned snapshots. when the database returns, the backlog drains and, if nothing retains references, memory falls back (modulo allocator behaviour from earlier).
under sync, those 200 runs would simply block for 30 seconds. nothing accumulates, but your latency SLO is violated, and callers may time out and retry, which creates new runs. so sync converts a memory problem into a latency problem, and a latency problem can become a duplicate-work problem. that is where idempotency for ai agents stops being optional.
Edge cases and failure modes
the allocator hides the recovery
symptom: the backlog drains (your metrics show pending writes at zero) but RSS stays at the high-water mark. counter-strategy: do not conclude “leak” from RSS alone. track the live object count and tracemalloc snapshots. if live memory drops while RSS does not, the problem is fragmentation, and the fix is process recycling at a safe boundary (between runs, never mid-run), or capping peak backlog so the high-water mark is lower. setting MALLOC_ARENA_MAX and trying jemalloc are common mitigations on Linux, but test them against your own workload since results vary.
retention through observability
symptom: memory grows even though the saver is fast. counter-strategy: check what your tracer, callback handlers, and stream consumers keep. a handler that appends every state update to a list “for later flushing” pins every snapshot regardless of durability mode. before blaming the checkpointer, run with tracing disabled for an hour and compare. if you are choosing tooling, my notes on the best tools for tracing and debugging ai agents cover what each one retains and for how long.
exit mode and the crash window
symptom: you switched to exit to stop the growth and a pod got evicted mid-run. now the whole run’s progress is gone, not just the last step. counter-strategy: exit is only appropriate for runs that are short, cheap to repeat, or naturally idempotent. for anything with side effects (payments, emails, ticket updates), you must either keep per-step durability or make every side effect safely replayable. there is no free lunch here: exit removes the backlog by removing the checkpoints. the durable execution docs are the place to read what resume guarantees you keep and lose in each mode, and i would re-read them against your pinned version, as of October 2026, before you rely on any specific recovery behaviour.
sync mode and tail latency
symptom: after moving to sync, p50 looks fine and p99 doubles, and callers start timing out. counter-strategy: sync makes your checkpoint store part of the critical path, so its tail latency is now your tail latency. put the database close to the workers (same region, and in my case same availability zone), size the pool for peak concurrency, and keep state small by storing large blobs outside the checkpoint and passing references. also set sensible client timeouts so a stalled write fails fast rather than holding a run open.
a missing table masquerading as a leak
symptom: during first setup, errors during checkpoint writes cause retries or pending work that never completes, and memory climbs. counter-strategy: make sure the saver is actually initialized. if your Postgres checkpointer was never set up, writes fail, and a pipeline that does not surface the failure loudly can pile up failed work. i wrote up the classic version of this in how to fix relation checkpoints does not exist the first time you wire up langgraph. rule it out first, it takes two minutes.
unbounded message history
symptom: even under sync, memory per thread grows turn by turn. counter-strategy: this is not durability at all. the state itself is growing because you append every message forever. trim or summarize history, cap tool output sizes before they enter state, and keep a hard limit on messages per thread. this also shrinks every snapshot, which makes any backlog under async proportionally cheaper.
What we learned in production
the first thing i learned is to measure before changing modes. on one fleet the symptom looked exactly like an async leak, and the actual cause was a tracer callback holding every state update. switching durability would have cost me latency and fixed nothing. my order of operations now is: confirm the saver is initialized, disable tracing for a window, take two tracemalloc snapshots an hour apart and diff them by traceback, and only then touch the durability setting. if the top growth sites point at checkpoint serialization or pending write tasks, the mode is the lever. if they point at your own handlers or message lists, it is not.
the second thing is that i stopped treating durability as a global default and started choosing it per graph. short, cheap, idempotent graphs run in exit. graphs with real side effects run in sync with a properly sized pool and a nearby database. async stays for the middle: slow-node conversational agents where the saver has enormous headroom. i also expose the pending-write count as a metric so a growing backlog pages me long before the OOM does. in the same spirit, my write-up on session lifecycles for long-running agents covers where i place the safe recycling boundaries that make the “restart between runs” strategy above actually safe.
here is the per-invocation switch, since the parameter is easy to miss. check the signature in your installed version, as of October 2026 it is accepted on the invoke and stream methods:
# short, replayable graph: persist once at exit
await graph.ainvoke(inputs, config, durability="exit")
# side effects: wait for each checkpoint before the next step
await graph.ainvoke(inputs, config, durability="sync")
# default: schedule writes in the background
await graph.ainvoke(inputs, config, durability="async")
so, the direct answer to the question in issue 7094 as far as i can give it: yes, async durability can make memory grow, but only in the specific condition where checkpoints are produced faster than the saver can acknowledge them and writes are ordered, so pending work and the snapshots it pins accumulate without a bound. switching to sync bounds memory by making the producer wait, and what you trade away is throughput and tail latency. switching to exit bounds memory by not checkpointing mid-run, and what you trade away is mid-run recovery. neither is a fix for retention bugs in your own code, and i would not call it a leak in the strict sense until the tracemalloc diff says the growth is in checkpoint write tasks.
References and further reading
- langgraph issue 7094: the reader question this article responds to. read the thread for the maintainers’ own findings, which i have not reproduced here.
- LangGraph durable execution docs: the three durability modes and what each guarantees.
- LangGraph persistence docs: checkpoints, threads, and savers.
- Python asyncio task documentation: task lifetime and reference semantics.
- Python tracemalloc documentation: snapshot and diff APIs for finding what grows.
more operator notes live on the blog index.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-25.