DEV Community

Cover image for LangGraph.js vs a While Loop: When the Graph Earns Its Complexity
Gabriel Anhaia
Gabriel Anhaia

Posted on

LangGraph.js vs a While Loop: When the Graph Earns Its Complexity


Adopting a graph framework for a first agent is a common default and
frequently the wrong call. Not because LangGraph.js is bad — it is
well built and it solves real problems — but because most first
agents do not yet have the problems it solves, and the abstraction
cost is paid immediately.

Here is the same agent both ways, and the four capabilities that
decide it.

The agent, as a loop

export async function run(task: string, ctx: Ctx) {
  const messages: MessageParam[] = [{ role: "user", content: task }];
  let turns = 0;
  let costUsd = 0;

  while (turns < 12 && costUsd < 0.5) {
    const res = await client.messages.create({
      model: "claude-opus-5",
      max_tokens: 2048,
      tools: toolDefs,
      messages,
    });

    turns++;
    costUsd += costOf("claude-opus-5", res.usage);
    messages.push({ role: "assistant", content: res.content });

    if (res.stop_reason !== "tool_use") {
      return { text: textOf(res.content), turns, costUsd };
    }

    const results = await Promise.allSettled(
      res.content
        .filter((b) => b.type === "tool_use")
        .map((b) => execute(b, ctx)),
    );
    messages.push({ role: "user", content: toResults(results) });
  }

  return { text: null, turns, costUsd, stopped: "limit" };
}
Enter fullscreen mode Exit fullscreen mode

Thirty lines. Whole control flow visible at once. Debuggable with a
breakpoint and a console.log. No library semantics to know.

The same agent, as a graph

import { Annotation, StateGraph, START, END, MemorySaver }
  from "@langchain/langgraph";

const State = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  turns: Annotation<number>({
    reducer: (a, b) => a + b,
    default: () => 0,
  }),
});

async function agent(state: typeof State.State) {
  const res = await model.bindTools(tools).invoke(state.messages);
  return { messages: [res], turns: 1 };
}

async function runTools(state: typeof State.State) {
  const last = state.messages.at(-1) as AIMessage;
  const out = await Promise.all(
    (last.tool_calls ?? []).map((c) => callTool(c)),
  );
  return { messages: out };
}

function route(state: typeof State.State): "tools" | typeof END {
  const last = state.messages.at(-1) as AIMessage;
  if (state.turns >= 12) return END;
  return last.tool_calls?.length ? "tools" : END;
}

const graph = new StateGraph(State)
  .addNode("agent", agent)
  .addNode("tools", runTools)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", route)
  .addEdge("tools", "agent")
  .compile({ checkpointer: new MemorySaver() });
Enter fullscreen mode Exit fullscreen mode

Roughly the same length, and more concepts: annotations, reducers,
node signatures, routers, compile-time wiring. If that were the whole
story, the loop wins on simplicity.

It is not the whole story.

The same control flow expressed as a linear loop and as a state graph.

What the graph actually buys

1. Durable execution

const config = { configurable: { thread_id: "run-42" } };
await graph.invoke({ messages: [msg] }, config);
Enter fullscreen mode Exit fullscreen mode

State is checkpointed after each node. The process dies at turn nine,
comes back, invokes with the same thread_id, and continues.

Adding this to the loop means serialising your state after each turn,
loading it at the top, and versioning the payload. Perfectly doable —
and it is the point where you have written a checkpointer, which is
the thing you were avoiding.

2. Branching with independent state

The loop is linear by construction. A workflow that fans out into
three independent investigations and merges the results is
expressible with Promise.all inside one turn, but each branch
sharing the same message array is exactly the coupling you were
trying to avoid.

A graph makes branches first-class, and reducers define how their
state merges when they rejoin.

3. Human-in-the-loop interrupts

import { interrupt, Command } from "@langchain/langgraph";

async function approve(state: typeof State.State) {
  const decision = interrupt("Approve this refund?");
  return { messages: [{ role: "user", content: String(decision) }] };
}

// later, possibly days later, from a different process
await graph.invoke(new Command({ resume: "yes" }), config);
Enter fullscreen mode Exit fullscreen mode

interrupt suspends the run, persists it, and returns. Command({
resume })
continues from that exact point in a new process.

This is the capability hardest to retrofit. It needs durable state,
a resume entry point, and the pause to be a first-class state rather
than a blocked promise. In a loop you would restructure the whole
function.

4. Streaming intermediate state

Showing a user what the agent is doing while it does it means the
loop must emit events. Turning a while loop into an async generator
is possible and it changes every call site.

Graphs stream node transitions and state updates natively, because
the framework already knows where the boundaries are.

The honest comparison

Loop Graph
Lines to first working agent ~30 ~50 + concepts
Debuggable with a breakpoint yes mostly
Survives a process restart no yes, with a durable checkpointer
Parallel branches with own state no yes
Pause days for human approval no yes
Stream intermediate state with rework yes
Dependencies SDK only plus framework
New-joiner ramp read one function learn the model

The middle four rows are the decision. If you need none of them, the
top and bottom rows say loop.

Where the loop actually hurts

Two experiences, in order of how often they happen.

You add persistence "just for restarts", then branching "just for
this one case", then a pause for approval. Six months later you have
a graph engine with no name, no tests of its own, and one author who
understands it. Adopting the framework at that point is a rewrite.

Or you never need any of it, and the loop is still thirty lines that
anyone can read.

Both are real. Which one you get depends on whether the product grows
toward long-running, interruptible workflows — which is a product
question, not a technical one, and it is worth asking before picking.

The four capabilities as a decision boundary between loop and graph.

What is fair to say about LangGraph.js

It is not overhead for its own sake. Every concept maps to something
you would build yourself: annotations are state channels, reducers are
merge semantics, checkpointers are durable state, interrupts are
suspended continuations. Those are the right primitives for
long-running agents, and it implements them properly.

The complaint worth taking seriously is the learning curve relative
to a first agent. Reducers in particular are the concept that bites
newcomers — a missing one on messages silently replaces history
instead of appending, and nothing errors.

My recommendation

Write the loop first. It takes an afternoon and it teaches you what
an agent actually is.

Move to a graph when you hit one of the four — durability, branching,
interrupts, streaming — and move because you hit it, not in
anticipation. The migration is real work but it is bounded, and by
then you will know which capability you are buying.

Reaching for the graph on day one because agents are supposed to have
one is how a two-week project becomes a six-week project with a
framework nobody on the team has debugged.


If this was useful

AI That Plans covers
LangGraph.js in depth — and is equally clear about where a plain loop
is the better engineering. State design, checkpointing, interrupts,
and multi-agent patterns, with the trade-offs stated.

AI That Plans — Stateful AI Agents with LangGraph.js

The loop version is book three. The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)