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.
The scoreboard says close to a wash. The decision, after measuring everything twice, is to adopt LangGraph — and the reasoning is the part worth reading.
The LangGraph version is 32 lines longer
than the loop it replaced, pulls in 27 extra dependencies,
and adds 0.449s to every run here.
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 decided it is that the gaps are not symmetric. Every sharp edge on the LangGraph side closed with configuration or a few lines in the caller — each fix committed and measured, with the original footgun still reproducible behind a flag. Every gap on the hand-rolled side is an unbuilt subsystem: no runaway guard, no human-in-the-loop that survives process death, no thread history. Configuration debt against construction debt is not a tie.
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 · 286 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 265, so the gap is not my prose. | 254 | 286 |
| Python distributions installed Two throwaway virtualenvs, built from scratch. The framework costs 27 extra. | 29 | 56 |
| site-packages on disk +51.5 MB. | 62.0 MB | 113.4 MB |
| Import time, median of 7 Importing exactly what each implementation needs. | 0.5099s | 1.0319s |
| Full baseline run, median of 7 Process start, MCP server spawn, 24 loop steps, exit. | 1.3595s | 1.8082s |
| 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. The framework default persists position, not verdict; the committed runner writes the verdict from the caller in two lines of aupdate_state and stays resumable. Both variants are measured. | yes, status failed plus the message | yes as committed, status failed; bare default says running with no reason |
| 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. Both statement orderings are measured; the committed code interrupts first. | 1 time | 1 time as committed; 2 times with the side effect above interrupt() |
| 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.
The LangGraph runner invokes with durability="sync", so a checkpoint lands
before the next super-step; the default overlaps the write with the next step, which is
exactly this window.
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:
s-langgraph/langgraph_impl/run.py", line 102, in main_async
digest = policy.state_digest(state)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/user/loop-vs-langgraph/tools/policy.py", line 107, in state_digest
"run_id": state["run_id"],
~~~~~^^^^^^^^^^
KeyError: 'run_id'
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. All three fixes are committed here and measured, with the original behaviour reproducible behind a flag.
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 runner reads the pending interrupt back out of the snapshot and re-attaches it to the digest. The reason survives; you just have to know where it lives.
With the record call above interrupt(), the escalation lands in the
durable run record 2 times against the
hand-rolled loop's 1. 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.
Committed fix, measured: interrupt() first, record after approval — back to 1. Footgun reproducible with LOOPLAB_ESCALATION_ORDER=record_first. Anything above an interrupt() must be idempotent; the official docs now say so too.
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.
Committed fix: recursion_limit=200, set explicitly at invoke.
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: committed / bare default |
|---|---|---|---|
Tool returns the wrong typeact returns cost_usd as the string "0.40" | ToolError | failed | failed / running |
Tool never answersact sleeps 600s against a 2s client deadline | ToolTimeout | failed | failed / running |
Tool raisesact throws inside the handler | ToolError | failed | 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.