agent_signal

Guide

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). 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.

Reproduce it, then fix it

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

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):

== 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:

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 starter pack

The approval-flow starter pack

Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.

Get tested agent templates weekly