# LangGraph interrupt resumes twice: why the node re-runs and how to fix it

By Agent Signal · Published 2026-10-15 · Updated 2026-10-15


You call `interrupt()`, the human approves, you resume with `Command(resume=True)`, and the API call, email or charge that sits above `interrupt()` happens a second time. This is not a bug in your checkpointer. It is how LangGraph resumes, and it is documented. This guide reproduces it, explains it, and shows the fixes, with code we ran on **langgraph 1.2.15 / langgraph-checkpoint 4.3.0 (Python), Oct 15, 2026**.

## Why it happens

From the LangGraph docs: on resume, "the runtime restarts the entire node from the beginning—it does not resume from the exact line where `interrupt` was called. This means any code that ran before the `interrupt` will execute again" ([LangGraph interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts)). `interrupt()` pauses by raising a special exception; on the second run it finds the resume value in the checkpoint and returns it instead of raising.

So "the interrupt fires twice" usually means one of three things:

1. **Code before `interrupt()` re-runs** (the common one): logs, LLM calls, writes, charges.
2. **You resumed on a different `thread_id`.** The docs: "Reusing it resumes the same checkpoint; using a new value starts a brand-new thread with an empty state."
3. **You looped `interrupt()` in one node** (e.g. a `while True` validation loop), so each resume replays every earlier iteration. See our [multiple interrupts guide](/guides/langgraph-multiple-interrupts-resume/).

## Reproduce it, then fix it

`CHARGES` stands in for a payment API. No LLM, no API key.

```python
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.types import Command, interrupt

class State(TypedDict, total=False):
    amount: int
    approved: bool
    charge_id: str

CHARGES = []  # stands in for a payment API

def charge(amount):
    CHARGES.append(amount)
    return f"ch_{len(CHARGES)}"

# --- BUG: side effect before interrupt() ---
def approve_and_charge(state: State):
    print("[node] start")
    charge_id = charge(state["amount"])          # runs again on resume!
    approved = interrupt({"question": f"Charge ${state['amount']}?"})
    return {"approved": approved, "charge_id": charge_id if approved else ""}

def build_buggy():
    b = StateGraph(State)
    b.add_node("approve_and_charge", approve_and_charge)
    b.add_edge(START, "approve_and_charge"); b.add_edge("approve_and_charge", END)
    return b.compile(checkpointer=InMemorySaver())

# --- FIX: interrupt in its own node, side effect in the next node ---
def approval(state: State):
    print("[approval] start")
    return {"approved": interrupt({"question": f"Charge ${state['amount']}?"})}

def do_charge(state: State):
    if not state["approved"]:
        return {}
    print("[do_charge] start")
    return {"charge_id": charge(state["amount"])}

def build_fixed():
    b = StateGraph(State)
    b.add_node("approval", approval); b.add_node("do_charge", do_charge)
    b.add_edge(START, "approval"); b.add_edge("approval", "do_charge"); b.add_edge("do_charge", END)
    return b.compile(checkpointer=InMemorySaver())

print("== buggy ==")
g = build_buggy(); cfg = {"configurable": {"thread_id": "order-1"}}
out = g.invoke({"amount": 79}, cfg); print("paused:", out["__interrupt__"][0].value)
out = g.invoke(Command(resume=True), cfg); print("result:", out, "| charges:", CHARGES)

print("== new thread_id on resume ==")
CHARGES.clear(); g = build_buggy()
g.invoke({"amount": 79}, {"configurable": {"thread_id": "order-2"}})
try:
    out = g.invoke(Command(resume=True), {"configurable": {"thread_id": "order-2-retry"}})
    print("result:", out)
except Exception as e:
    print("error:", type(e).__name__, e)
print("state of retry thread next:", g.get_state({"configurable": {"thread_id": "order-2-retry"}}).next)

print("== fixed ==")
CHARGES.clear(); g = build_fixed(); cfg = {"configurable": {"thread_id": "order-3"}}
out = g.invoke({"amount": 79}, cfg); print("paused:", out["__interrupt__"][0].value)
out = g.invoke(Command(resume=True), cfg); print("result:", out, "| charges:", CHARGES)
```

Real output (`pip install langgraph==1.2.15`):

```text
== buggy ==
[node] start
paused: {'question': 'Charge $79?'}
[node] start
result: {'amount': 79, 'approved': True, 'charge_id': 'ch_2'} | charges: [79, 79]
== new thread_id on resume ==
[node] start
[node] start
error: KeyError 'amount'
state of retry thread next: ('approve_and_charge',)
== fixed ==
[approval] start
paused: {'question': 'Charge $79?'}
[approval] start
[do_charge] start
result: {'amount': 79, 'approved': True, 'charge_id': 'ch_1'} | charges: [79]
```

What this shows:

- **Buggy:** `[node] start` prints twice and the customer is charged twice (`charges: [79, 79]`), even though the human approved once.
- **New `thread_id` on resume:** LangGraph has no paused task on `order-2-retry`, so it starts that thread from `START` with empty state and the node crashes with `KeyError: 'amount'` (in your app it may instead silently start a fresh run and interrupt again). Always resume with the exact `thread_id` you paused on, and store it with the pending approval.
- **Fixed:** the approval node still re-runs (`[approval] start` twice, which is expected and harmless because it has no side effects) but `do_charge` runs once: `charges: [79]`.

## The three fixes

1. **Move the side effect into the next node** (shown above). The node holding `interrupt()` should only ask and return the answer. This is the docs' recommended "separate side effects into separate nodes."
2. **Put the side effect after `interrupt()`** in the same node. Code after the call only runs once the resume value exists.
3. **Make unavoidable pre-interrupt work idempotent**: upserts instead of inserts, and an idempotency key (e.g. derived from `thread_id` + tool call id) on payment or email APIs, so a replay is a no-op.

Also check: never wrap `interrupt()` in a bare `try/except Exception`, which swallows the pause; and use a durable checkpointer (SQLite/Postgres) in production, since `InMemorySaver` is lost on restart (durable savers were not tested here).

## Tested starters

- Free: [LangGraph human-in-the-loop approval graph](/templates/langgraph-human-in-the-loop/), propose → approval → execute, with real run output.
- Pro: [Idempotent approval: no double charges on resume](/templates/langgraph-idempotent-approval/), the idempotency-key pattern, in [Signal Pro](/pricing/) ($7/month or $69/year).
- Done for you: the [$79 Agent Approval-Flow Fix](/fix/). We repair approval and resume in your LangGraph agent and deliver a tested patch within 48h, or a full refund.
