Does LangGraph's interrupt() Guarantee Exactly-Once Execution? We Tested It.
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.
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=94993Run 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=95252Reading 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.
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