Does LangGraph's interrupt() Guarantee Exactly-Once Execution? We Tested It.

·7 min read

The claim, up front: LangGraph's native interrupt()/Command({ resume }) pattern — the one the LangGraph Starter Kit uses for human-in-the-loop — does not make an approved action execute exactly once under concurrency. We didn't infer this from reading the code. We cloned the repo, ran it against a real Postgres checkpointer and the real claude-sonnet-4-5 API, and reproduced the double-execution twice. This page is the method and the raw output, not just the conclusion — reproduce it yourself with the commands at the bottom.

Setup

Fresh clone of ac12644/langgraph-starter-kit, npm install, its own 39 tests passing unmodified. A throwaway Postgres container for the checkpointer. A real ANTHROPIC_API_KEY against claude-sonnet-4-5 — not a stub or a scripted demo model. One line added to its delete_record tool: a side-effect ledger, appended after the interrupt resolves, standing in for a real destructive action:

if (LEDGER_PATH) {
  appendFileSync(LEDGER_PATH, `${new Date().toISOString()} deleted ${args.id} pid=${process.pid}\n`);
}

No other logic in the repo was touched.

Method

Asked its own db_admin agent to delete a record. It paused on interrupt(), exactly as designed. Then, on the same paused thread, ran what its /:app/resume HTTP route does internally — app.invoke(new Command({ resume: "yes" }), config) — eight times concurrently with Promise.allSettled. Every caller's HTTP response was recorded. The ledger — the real side effect — was checked separately.

8 concurrent resume calls at one paused approval: 8 of 8 executions vs 1 of 88 callers fire Command({resume: 'yes'}) at the same paused thread_id simultaneously. Against the LangGraph Starter Kit's unmodified interrupt()-only resume path, the real side-effect ledger recorded 8 of 8 duplicate executions in this run (5 of 8 in an earlier run), while every caller was told it succeeded. The identical race against agentFast's compare-and-set approval gate recorded exactly 1 of 8, every run.caller 0caller 1caller 2caller 3caller 4caller 5caller 6caller 7same thread_id · Command(resume: "yes") × 8LangGraph Starter Kit — interrupt() onlyHTTP response: 8 of 8 said "deleted successfully"8 / 8real ledger writes — expected 1second run: 5 of 8 — never lands on 1no compare-and-set on the resume pathagentFast — CAS on executed_atHTTP response: 8 of 8 told the outcome1 / 8real ledger write — exactly what's expectedevery run, same result7 losers replay the winner's result, no re-run

Run 1 — raw output

--- Step 1: ask the real model to delete rec_1 (should pause on interrupt) ---
Graph paused. next: [ 'tools' ]

--- Step 2: fire 8 concurrent resume('yes') calls at the SAME thread_id ---
  caller 0: OK -> "The record rec_1 has been deleted successfully."
  caller 1: OK -> "The record rec_1 has been deleted successfully."
  caller 2: OK -> "The record rec_1 has been deleted successfully."
  caller 3: OK -> "The record rec_1 has been deleted successfully."
  caller 4: OK -> "The record rec_1 has been deleted successfully."
  caller 5: OK -> "The record rec_1 has been deleted successfully."
  caller 6: OK -> "The record rec_1 has been deleted successfully."
  caller 7: OK -> "The record rec_1 has been deleted successfully."

--- Ledger (real side-effect count): 5 deletion(s) actually executed ---
  2026-08-05T14:04:30.673Z deleted rec_1 pid=94993
  2026-08-05T14:04:30.686Z deleted rec_1 pid=94993
  2026-08-05T14:04:30.691Z deleted rec_1 pid=94993
  2026-08-05T14:04:30.708Z deleted rec_1 pid=94993
  2026-08-05T14:04:30.720Z deleted rec_1 pid=94993

Run 2 — fresh thread, raw output

--- Step 1: ask the real model to delete rec_1 (should pause on interrupt) ---
Graph paused. next: [ 'tools' ]

--- Step 2: fire 8 concurrent resume('yes') calls at the SAME thread_id ---
  caller 0: OK -> "The record rec_1 has been deleted successfully."
  caller 1: OK -> "The record rec_1 has been deleted successfully."
  caller 2: OK -> "The record rec_1 has been deleted successfully."
  caller 3: OK -> "The record rec_1 has been deleted successfully."
  caller 4: OK -> "The record rec_1 has been deleted successfully."
  caller 5: OK -> "The record rec_1 has been deleted successfully."
  caller 6: OK -> "The record rec_1 has been deleted successfully."
  caller 7: OK -> "The record rec_1 has been deleted successfully."

--- Ledger (real side-effect count): 8 deletion(s) actually executed ---
  2026-08-05T14:05:02.959Z deleted rec_1 pid=95252
  2026-08-05T14:05:02.966Z deleted rec_1 pid=95252
  2026-08-05T14:05:02.972Z deleted rec_1 pid=95252
  2026-08-05T14:05:02.988Z deleted rec_1 pid=95252
  2026-08-05T14:05:02.993Z deleted rec_1 pid=95252
  2026-08-05T14:05:02.999Z deleted rec_1 pid=95252
  2026-08-05T14:05:03.003Z deleted rec_1 pid=95252
  2026-08-05T14:05:03.007Z deleted rec_1 pid=95252

Reading the result

Every one of the sixteen HTTP calls across both runs got back a success message. Zero of them got an error. The ledger is what actually happened: 5 of 8 real executions in run 1, 8 of 8 in run 2 — never once landing on the one execution a “this cannot be undone” action is supposed to get. That's not variance in whether the bug exists; it's variance in how bad it is on a given race.

We ran the identical eight-way race against agentFast's own approval path — same concurrency, same kind of real ledger counting side effects instead of database rows — and got exactly one execution, every time, because the resume path claims the action with a compare-and-set on executed_at in Postgres before running it, instead of just re-entering the tool body on every resume.

Reproduce it yourself

git clone https://github.com/ac12644/langgraph-starter-kit.git
cd langgraph-starter-kit && npm install

# Postgres checkpointer + a real Anthropic key
docker run -d -p 5599:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg16
cat > .env <<EOF
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
LLM_MODEL=claude-sonnet-4-5
DATABASE_URL=postgresql://postgres:postgres@localhost:5599/postgres
EOF

# add the one-line ledger to src/apps/interrupt.ts's delete_record tool,
# then fire Promise.allSettled([...8x app.invoke(new Command({resume:"yes"}), config)])
# at a thread paused on interrupt(), and read the ledger file afterward.

Full narrative comparison, licensing, and what LangGraph Starter Kit does well: agentFast vs LangGraph Starter Kit.

Stop rebuilding the plumbing.

agentFast is the production layer — memory, 50 tools, observability, evals, guardrails, human-in-the-loop — for LangGraph, CrewAI, the Claude & OpenAI Agent SDKs & Vanilla. Own it for life.

Get agentFast — from $89