← All posts

LangGraph clicked when I stopped thinking in chains

10 min read
LangGraphAgentsJavaScript

LangChain made sense to me on day one. Call a model, pass the output somewhere, call another thing. Chain.

LangGraph took longer. I stared at the docs, nodded along at the diagrams, and still thought: why do I need a whole graph library to call an LLM?

Then I tried to build something that had to (1) call a tool, (2) look at what came back, (3) decide if that was enough, and (4) sometimes go try again. My “chain” turned into a pile of ifs and a while I was embarrassed of. That’s when the graph stopped feeling like ceremony.

Chains are for the happy path

Most demos are happy paths. User asks → model answers. Or user asks → retrieve → answer. Fine. A chain is enough.

Real workflows get messy fast:

  • the tool failed, try a different one
  • the retrieved docs are garbage, rewrite the query
  • the answer isn’t grounded, go fetch more
  • a human has to approve before you send the email

That’s not a chain. That’s a flowchart someone would draw on a whiteboard. LangGraph is basically “make the whiteboard executable.”

LangChain vs LangGraph (how I keep them straight)

I got confused because the names sound like siblings competing for the same job. They’re not.

LangChain = adapters. Models, prompts, tools, output parsers. The boring glue so you’re not rewriting OpenAI/Anthropic/Ollama client code every week.

LangGraph = control flow. Who runs next. What state they share. When to loop. When to stop.

You still use LangChain stuff inside graph nodes. Graph decides the route; chain-y bits do the model work.

import { ChatOllama } from '@langchain/ollama';

const model = new ChatOllama({
	baseUrl: 'http://localhost:11434',
	model: 'llama3.1:8b'
});

const reply = await model.invoke('Explain reducers in two sentences.');
console.log(reply.content);

Swap the provider later, keep invoke(). Not exciting. That’s the point.

Make the model return fields, not essays

I wasted a week parsing free-form text with regex. “Please reply with YES or NO” — the model replied with a paragraph that contained yes.

Structured output fixed more bugs than prompt tweaking:

import { z } from 'zod';

const Decision = z.object({
	route: z.enum(['search', 'answer']),
	reason: z.string()
});

const router = model.withStructuredOutput(Decision);
const decision = await router.invoke(
	'Can this question be answered from the conversation alone?'
);
// decision.route is actually usable

If it fails validation, fail at the boundary. Retry or surface an error. Don’t let half-parsed junk become graph state.

Tools don’t run themselves

This one bit me. You “give the model a tool” and somehow expect the tool to execute. No. The model returns a request: name + args. Your code still has to:

  1. validate the args (please validate the args)
  2. run the function
  3. stick the result back in the messages
  4. call the model again

That loop is the whole agent thing:

Agent loop

One decision can send the graph around again.

shared state
START

01Model

Reads state and proposes the next move.

Route the result

Tool call or final answer?

↻ Tools

Run the call, add its result to state, then return to the model.

Answer → END

Return the settled result to the user.

Nodes do the work. Edges choose what runs next. State is the notebook every step can read and update.

Stop when you get a normal answer. Also stop when you’ve looped too many times. A model that keeps calling the same broken tool will happily burn money forever if you let it.

The three words that matter

  1. State — shared bag of data for the run
  2. Nodes — functions that read state, do work, return updates
  3. Edges — who goes next

Tiny example:

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

const WorkflowState = Annotation.Root({
	question: Annotation(),
	category: Annotation()
});

const graph = new StateGraph(WorkflowState)
	.addNode('clean', ({ question }) => ({
		question: question.trim()
	}))
	.addNode('classify', async ({ question }) => ({
		category: await classify(question)
	}))
	.addEdge(START, 'clean')
	.addEdge('clean', 'classify')
	.addEdge('classify', END)
	.compile();

const result = await graph.invoke({ question: '  How do reducers work?  ' });

Nodes return updates, they don’t mutate a shared object in place. That matters once two nodes run at once — there’s a defined merge step instead of a race.

.compile() is underrated. It catches “you forgot to connect this node” before production does.

Conditional edges = the whole reason I switched

Normal edge: always go to B.
Conditional edge: look at state, pick a destination.

const routeEvidence = ({ evidence }) =>
	evidence.length >= 2 ? 'write' : 'searchAgain';

const graph = new StateGraph(WorkflowState)
	.addNode('search', search)
	.addNode('grade', gradeEvidence)
	.addNode('rewriteQuery', rewriteQuery)
	.addNode('write', writeAnswer)
	.addEdge(START, 'search')
	.addEdge('search', 'grade')
	.addConditionalEdges('grade', routeEvidence, {
		write: 'write',
		searchAgain: 'rewriteQuery'
	})
	.addEdge('rewriteQuery', 'search') // <-- the loop
	.addEdge('write', END)
	.compile();

That edge back to search is what chains hate. Put an attempt counter in state. After 2–3 tries, bail to a “sorry, I don’t know” node. Loops without an exit condition aren’t agents. They’re bugs with branding.

Parallel branches need reducers

Say three nodes all write to sources at the same time. Without a reducer, last write wins and you silently lose work. Fun to debug at 1am.

const ResearchState = Annotation.Root({
	query: Annotation(),
	sources: Annotation({
		reducer: (current, incoming) => [...current, ...incoming],
		default: () => []
	})
});

Message history uses the same idea — append, don’t replace the whole chat every time.

createReactAgent is still a graph

There’s a prebuilt ReAct agent. Use it when the only branch is “did the model ask for a tool?”

import { createReactAgent } from '@langchain/langgraph/prebuilt';

const agent = createReactAgent({
	llm: model,
	tools: [searchDocs, inspectRepository],
	prompt: 'Use tools when you need evidence. Never invent a tool result.'
});

const result = await agent.invoke({
	messages: [{ role: 'user', content: 'Why did the deployment fail?' }]
});

Under the hood: message state, model node, tool node, conditional edge, stop condition. Once you see that, you know when the helper is enough and when you need your own graph.

When I actually reach for it

Worth it when:

  • something has to be checked before continuing
  • tools might need multiple round trips
  • branches can run in parallel
  • work has to pause and resume later
  • a human has to approve a risky step

Not worth it for: one prompt, one answer. That’s model.invoke(). Don’t graph-ify a hello world.

The shift for me wasn’t “LangGraph is cooler.” It was: uncertain model calls need explicit control flow, and graphs are a decent way to write that down without lying to yourself about how simple the system is.

got thoughts?

Let's talk

© 2026 Prashansa Soni