Once by hand. Once on LangGraph. Both driving one shared, schema-validated MCP tool layer that neither implementation is allowed to bypass. Then killed, timed, counted and crashed on purpose.
It is close to a wash, and the ways it is not a wash are not the ways you would guess.
The LangGraph version came out 23 lines longer
than the loop it replaced, pulled in 27 extra dependencies,
and added 0.267s to every run.
On crash-safe checkpoint and resume, the thing people actually adopt it for,
the two are exactly tied: both recover byte-identical state under a real
kill -9, and both re-execute the in-flight step exactly once.
What LangGraph genuinely buys is a human-in-the-loop primitive that survives process death, a checkpoint store that keeps the whole thread rather than just the head, and a runaway guard the hand-rolled loop does not have. What it costs is three sharp edges that stay invisible until something goes wrong.
The most useful finding belongs to neither. The durable run record, the one artifact this whole exercise was about protecting, was the one piece of state that neither checkpointer protected, because it lived in the tool layer. Durability is a property of a system, not of an orchestrator.
Six nodes, one shared policy module, one shared MCP tool layer. Only the wiring and the persistence differ.
no framework · 254 lines
state.json, re-enter at next_node--approve flaglanggraph 1.2.9 · 277 lines
thread_id with Noneinterrupt() then Command(resume=...)Every number below is worthless if the two loops behave differently. All four scenarios, both implementations, digests diffed.
| Scenario | Outcome | Status | Spent | Fields that differ |
|---|---|---|---|---|
baseline | identical | done | $1.80 | none |
irreversible | identical | escalated | $0.60 | history, records |
overspend | identical | escalated | $0.60 | history, records |
verify_stuck | identical | escalated | $1.40 | history, records |
Both loops stop in the same place, before the same step, having spent the same amount, on every scenario. The three escalating scenarios differ on exactly two bookkeeping fields, and that divergence is finding 04 below, not a defect in the port.
Shaded cell means that column measured better. Blank on both sides means a tie.
| Measurement | By hand | LangGraph |
|---|---|---|
| Code lines for the loop itself Shared layer, identical for both: 472 lines. Blanks and comment-only lines excluded. With docstrings also stripped it is 231 against 256, so the gap is not my prose. | 254 | 277 |
| Python distributions installed Two throwaway virtualenvs, built from scratch. The framework costs 27 extra. | 29 | 56 |
| site-packages on disk +28.2 MB. | 46.9 MB | 75.1 MB |
| Import time, median of 7 Importing exactly what each implementation needs. | 0.3192s | 0.5615s |
| Full baseline run, median of 7 Process start, MCP server spawn, 24 loop steps, exit. | 0.7133s | 0.9804s |
| Recovers identical state after SIGKILL Killed 1.0s into a 3.0s tool call, then resumed. | yes | yes |
| Recovers identical state after a crash mid-commit Process death after the tool returned, before anything was persisted. | yes | yes |
| Side-effecting steps replayed on resume At-least-once at the node boundary, in both. Neither gives you exactly-once. | 1 step, once | 1 step, once |
| Canonical checkpoint file is self-sufficient Copy the file alone and resume from it. state.json is 1,407 bytes; checkpoints.sqlite is 4,096 bytes with 251,352 bytes sitting in a -wal sidecar. | yes | no |
| Persisted state says why the run died After an injected mid-run exception. Most of this gap is a choice I made, not the framework: the hand-rolled driver owns the state dict and writes a verdict on the way out. LangGraph can do the same in two lines via aupdate_state, tested. The finding is that it does not by default. | yes, status failed plus the message | not by default, status running |
| Halts before an irreversible action And a separate process can answer the halt in both. | yes | yes |
| Escalation written to the run record Resuming an interrupt re-runs the whole node, including side effects above it. | 1 time | 2 times |
| Runaway protection The baseline run needs 24 super-steps. Measured: it fails at 23. | none | recursion_limit, default 25 |
| Human-in-the-loop primitive interrupt() plus Command(resume=...), and it survives process death. | 14 lines, written for this test | built in |
Two kill points. The second one is the one that costs money.
The act step on s2 was slowed to 3 seconds and the whole process group
was killed 1.0s in. Nothing had returned yet, so in principle nothing should be lost.
Both implementations resumed to byte-identical final state. Neither duplicated a
single tool call. The hand-rolled loop left no temp files behind and never wrote a
checkpoint that failed to parse.
Process death at the end of the act node, after the tool returned and
before either implementation persisted anything. Both resumed to byte-identical final
state, and both re-ran that one step exactly once. At-least-once at the node boundary
is the semantics in both cases. Neither gives you exactly-once without idempotent tools.
This is what a backup script does. The hand-rolled loop's
state.json is 1,407 bytes and resumed
to completion on its own. LangGraph's checkpoints.sqlite is
4,096 bytes, because SQLite was in WAL mode
and the entire run was sitting in a 251,352 byte
-wal sidecar. Copying the file the name points at loses the run completely:
FAILED EmptyInputError: Received no input for __start__
Not LangGraph's bug. Standard SQLite, inherited from the checkpointer. Worth knowing anyway, because the filename tells you something untrue.
All three are defensible design consequences. All three will surprise someone in production.
When a tripwire fires, LangGraph's persisted state says nothing about it. The reason
lives only on the pending task's interrupt payload, and the caller has to know to go and
read it back. The hand-rolled loop commits status: escalated and the
tripwire text before it stops, so its own state says why it stopped.
The escalation was written to the durable run record 2 times by LangGraph and 1 time by the hand-rolled loop. The code reads correctly, the run completes correctly, the final state is correct, and the only evidence of the double write is in the tool layer's own append-only log.
Verified fix: move interrupt() above the side effect. Re-measured: back to 1.
The baseline scenario needs 24 super-steps. The default
recursion_limit is 25. Measured directly: it completes at 24 and
raises GraphRecursionError at 23. One more verify retry is four more
super-steps. The flip side, stated plainly: the hand-rolled loop has no runaway
protection at all, and would have spun forever.
Three faults injected at the MCP boundary. Both loops saw an identical error surface and the same exit code. What differed was what survived on disk.
| Fault | What both loops saw | By hand: persisted status | LangGraph: persisted status |
|---|---|---|---|
Tool returns the wrong typeact returns cost_usd as the string "0.40" | ToolError | failed | running |
Tool never answersact sleeps 600s against a 2s client deadline | ToolTimeout | failed | running |
Tool raisesact throws inside the handler | ToolError | failed | running |
The schema violation never reached either loop as bad data. The
frozen tool-defs.json is loaded verbatim as both inputSchema and
outputSchema, and the MCP server rejected its own handler's output before it
went back on the wire. That is the tool layer earning its keep, independent of either framework.
This list matters as much as the results.
Send, and state
reducers under parallel writes are a large part of what a framework earns its keep on, and
none of it is tested here.AsyncSqliteSaver only. The Postgres saver
has different durability and portability behaviour and was not tested.