LangGraph Common Pitfalls: Infinite Loops and State Bloat
The demo works. Then it doesn't.
You built a LangGraph agent. It reasons, calls a tool, reflects, calls another tool, and returns a clean answer. You showed it to your team. Everyone nodded. Then you shipped it, and by Thursday afternoon someone pinged you asking why the same customer support session burned four dollars in API calls and still didn't return an answer.
This is the most common way LangGraph projects go sideways. Not because the framework is broken, but because a graph is a machine that will happily keep running in circles if you don't tell it precisely when to stop, and it will happily keep passing state forward if you don't tell it what to forget. Infinite loops and state bloat are the two failure modes that show up in almost every LangGraph postmortem, and they're both avoidable once you understand why they happen.
This article walks through the actual mechanics of both problems, shows the code patterns that cause them, and gives you the fixes that experienced LangGraph builders reach for. If you're past the "hello world" stage and starting to run agents against real workloads, this is the stuff that will save you a debugging afternoon.
Why loops happen in the first place
LangGraph models an agent as a graph of nodes connected by edges, with a shared state object that flows between them. Nodes can route conditionally — an LLM node might decide "call the search tool again" instead of "answer now." That conditional routing is the entire point of the framework. It's also exactly where infinite loops are born.
Consider the classic ReAct-style pattern: an LLM node decides whether to call a tool or finish, a tool node executes and returns results, and a conditional edge sends control back to the LLM. This is a legitimate, common design. The problem is that nothing in the graph structure itself guarantees the LLM will eventually choose "finish." If the model gets stuck on an ambiguous tool result, misreads an error as "try again," or the tool node keeps returning something the LLM interprets as incomplete, the graph will cycle forever — or at least until something external stops it.
Here's a minimal version of a graph that's structurally sound but has no loop protection:
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
next_action: str
def call_model(state: AgentState) -> AgentState:
response = llm.invoke(state["messages"])
action = "tool" if "search(" in response.content else "finish"
return {"messages": [response], "next_action": action}
def call_tool(state: AgentState) -> AgentState:
result = run_tool(state["messages"][-1].content)
return {"messages": [result], "next_action": "model"}
graph = StateGraph(AgentState)
graph.add_node("model", call_model)
graph.add_node("tool", call_tool)
graph.set_entry_point("model")
graph.add_conditional_edges(
"model",
lambda state: state["next_action"],
{"tool": "tool", "finish": END}
)
graph.add_edge("tool", "model")Nothing here is wrong syntactically. But if call_model never confidently returns "finish" — say the tool keeps returning slightly malformed data and the model keeps retrying the same search with minor phrasing changes — this graph will run until it hits LangGraph's default recursion limit, and you'll get a GraphRecursionError in production, usually with no useful context about *why* it looped.
The recursion limit is a symptom, not a fix
A lot of teams' first encounter with this problem is the error message itself:
GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition.The instinct is to bump the limit:
app.invoke(inputs, config={"recursion_limit": 100})This is almost always the wrong move on its own. Raising the limit doesn't fix the loop — it just makes the loop more expensive before it fails. If your agent is looping because of a genuine logic bug, giving it 100 iterations instead of 25 means you burn four times the tokens finding out it still doesn't converge. Treat the recursion limit as a safety net, not a dial you turn up until the error goes away. The actual fix is to build explicit loop detection into your graph state, not to tolerate longer loops.
A better pattern is to track iteration count in state and force a decision once you cross a threshold:
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
next_action: str
loop_count: int
def call_model(state: AgentState) -> AgentState:
loop_count = state.get("loop_count", 0) + 1
if loop_count > 6:
# Force termination with a degraded but honest answer
return {
"messages": [AIMessage(content="I wasn't able to find a confident answer after several attempts.")],
"next_action": "finish",
"loop_count": loop_count,
}
response = llm.invoke(state["messages"])
action = "tool" if "search(" in response.content else "finish"
return {"messages": [response], "next_action": action, "loop_count": loop_count}This does two important things. First, it guarantees termination regardless of what the LLM decides, which means your worst case is a graceful fallback message instead of a crash or a runaway bill. Second, it makes the loop visible in your state — you can log loop_count and immediately tell, from your traces, which conversations are struggling instead of digging through raw message history after the fact.
The subtler loop: state that never changes
There's a second, sneakier version of the infinite loop problem: a graph that keeps cycling not because the LLM keeps saying "try again," but because the tool call it's retrying is deterministic and always returns the same failure. If a node calls a flaky API, gets a timeout, and the model responds by calling the exact same tool with the exact same arguments, you get a loop that looks like retry logic but is actually a no-op loop — same input, same failure, forever.
The fix here is to detect repeated tool calls with identical arguments and short-circuit them:
def call_tool(state: AgentState) -> AgentState:
last_call = state["messages"][-1]
tool_name, tool_args = parse_tool_call(last_call)
recent_calls = state.get("recent_tool_calls", [])
signature = (tool_name, tool_args)
if recent_calls.count(signature) >= 2:
return {
"messages": [ToolMessage(
content=f"Tool '{tool_name}' failed repeatedly with the same arguments. Try a different approach.",
tool_call_id=last_call.tool_calls[0]["id"],
)],
"recent_tool_calls": recent_calls + [signature],
}
result = run_tool(tool_name, tool_args)
return {
"messages": [result],
"recent_tool_calls": recent_calls + [signature],
}Notice that we're not silently blocking the retry — we're feeding the model a message that explains *why* the retry didn't happen, which usually nudges it toward a different strategy instead of just failing the same way a third time. This matters: infinite loop fixes that just kill execution without informing the model tend to produce a graph that "works" but always ends with a curt failure message. Telling the model what happened lets it actually route around the problem.
State bloat: the quieter, more expensive failure
Infinite loops are loud — they throw errors and someone notices. State bloat is quiet, and that's what makes it worse. It doesn't crash your graph. It just makes every single invocation slower and more expensive, and it usually creeps up over weeks rather than appearing in your first test run.
The root cause is almost always the same thing: LangGraph state accumulates by default when you use reducers like operator.add on message lists, and if nothing ever trims that list, every node in a long-running conversation re-sends the entire history to the LLM on every single call. A conversation that starts at 500 tokens can be at 40,000 tokens by turn thirty, and every one of those turns is now paying for the full history, not just the new turn.
Here's what that looks like in practice — a completely ordinary-looking state definition that will bloat over time:
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
def call_model(state: AgentState) -> AgentState:
# Every call sends the FULL message history to the LLM
response = llm.invoke(state["messages"])
return {"messages": [response]}This works fine in a demo where the conversation is five turns long. It becomes a real cost problem in a production support bot that runs for forty turns, or an agent that loops through fifteen tool calls before finishing a task. Each of those tool calls and intermediate reasoning steps stays in messages forever unless you actively prune it.
Pruning strategies that actually work
There are three approaches worth knowing, and most serious LangGraph apps end up combining at least two of them.
1. Windowing — only send the last N messages to the LLM, while keeping the full history in state for logging or auditing.
def call_model(state: AgentState) -> AgentState:
recent_messages = state["messages"][-10:]
response = llm.invoke(recent_messages)
return {"messages": [response]}This is the simplest fix and often good enough, but it has a real failure mode: if something important happened at turn 3 (a constraint the user gave, a decision the agent made), and you're now at turn 35, a naive window silently drops that context. Windowing works best when paired with a summary of what got dropped.
2. Summarization — periodically collapse older messages into a compact summary node, so the LLM sees "here's what happened so far" instead of the raw transcript.
def summarize_if_needed(state: AgentState) -> AgentState:
messages = state["messages"]
if len(messages) <= 20:
return {}
old_messages = messages[:-10]
recent_messages = messages[-10:]
summary_prompt = f"Summarize this conversation history concisely, preserving key facts and decisions:\n{old_messages}"
summary = llm.invoke(summary_prompt)
return {"messages": [SystemMessage(content=f"Summary of earlier conversation: {summary.content}")] + recent_messages}This costs you an extra LLM call each time you summarize, but it's usually far cheaper than the alternative of re-sending an ever-growing transcript on every single turn. Run this as its own node in the graph, triggered by a conditional edge that checks message count, rather than bolting it into your main reasoning node.
3. Selective state, not one giant blob — this is the fix people skip most often. Don't put everything into messages. If your agent is tracking a document draft, a list of retrieved sources, and a running todo list, those don't need to live inside the chat history at all. Give them their own state keys:
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
retrieved_sources: list[dict]
draft: str
todo_items: list[str]Then only pull messages into the LLM call, and reference draft or retrieved_sources explicitly where they're needed. This keeps your conversational history lean and makes debugging much easier, because when you inspect state you can see "the draft is here, the sources are here" instead of hunting through a message list for the one tool result that mattered.
A state bloat trap that's easy to miss: reducers on the wrong fields
A specific mistake worth calling out on its own: applying operator.add (or any accumulating reducer) to a field that should be *replaced*, not appended to. This is an easy typo to make and a painful one to debug, because your state silently grows even though every node "looks" like it's just updating a value.
# Bug: retrieved_sources keeps growing across every retrieval call,
# even old, irrelevant sources from three tool calls ago
class AgentState(TypedDict):
retrieved_sources: Annotated[list[dict], operator.add]
# Fix: only accumulate what genuinely should accumulate (conversation
# history). Anything that represents "current state" should overwrite.
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
retrieved_sources: list[dict] # no reducer = last write winsIf you're not intentional about which fields use accumulating reducers, you end up with state that grows in ways you didn't plan for, and it's not obvious from reading the node functions — you have to go back to the TypedDict definition to see it. Make it a habit to ask, for every field: "should this field remember everything that ever happened, or just the current value?" Message history is almost always the former. Nearly everything else is the latter.
Debugging tools you should actually be using
LangGraph ships with tracing support through LangSmith, and if you're debugging loops or bloat without it, you're working with one hand tied behind your back. Turn it on early:
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = "my-agent-debug"With tracing enabled, you can see the exact sequence of node executions, the state diff at each step, and the token count going into every LLM call. This turns "why did this loop six times" from a guessing game into a five-minute trace inspection. Pair that with logging your loop_count and message-list length at every node, and you'll usually spot both failure modes long before a user does.
It's also worth adding a simple assertion in development that fails loudly if state size crosses a sane threshold:
def guard_state_size(state: AgentState) -> None:
approx_tokens = sum(len(str(m)) for m in state["messages"]) // 4
if approx_tokens > 8000:
raise ValueError(f"State ballooning: ~{approx_tokens} tokens in messages")Call this at the top of your main reasoning node during development and staging. It's noisy on purpose — you want to know the moment your state starts drifting, not after it's already cost you money in production.
Putting it together: a graph with both guards in place
The combination that holds up in real deployments looks like this: an explicit loop counter with a forced termination path, repeated-tool-call detection, and a summarization step that keeps message history bounded. None of these are exotic techniques — they're just discipline applied to a framework that gives you enough rope to hang yourself if you assume it will stop on its own.
graph = StateGraph(AgentState)
graph.add_node("model", call_model)
graph.add_node("tool", call_tool)
graph.add_node("summarize", summarize_if_needed)
graph.set_entry_point("model")
graph.add_conditional_edges(
"model",
lambda state: state["next_action"],
{"tool": "tool", "finish": END}
)
graph.add_conditional_edges(
"tool",
lambda state: "summarize" if len(state["messages"]) > 20 else "model",
{"summarize": "summarize", "model": "model"}
)
graph.add_edge("summarize", "model")This graph terminates deterministically no matter what the LLM decides, keeps its message history bounded, and gives you a place to hook in the debugging guards above. It's not more complex than the naive version — it's just intentional about the two things naive versions leave to chance.
Where to go from here
Infinite loops and state bloat aren't edge cases in LangGraph — they're the default behavior of any cyclic graph with unbounded state, and every non-trivial agent you build is exactly that. The good news is the fixes are mechanical once you know to look for them: cap your iterations explicitly, detect repeated failures instead of retrying blindly, and treat your state schema as something to actively prune rather than a bucket you keep appending to.
If you want to go deeper into building production-grade agent graphs — including checkpointing, human-in-the-loop interrupts, and multi-agent supervisor patterns that compound these same issues at scale — check out the LangGraph Tutorial course on teachyou.ai. It walks through these exact failure modes with runnable examples, so you can see the loop happen, then fix it, instead of just reading about it.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.