Skip to content

A tool runs twiceafter resume.

You added an approval step to an agent and the email now goes out twice. The cause is where a resume starts, and any framework that saves progress between steps can do the same.

What a resume re-runs

A checkpointing framework saves the agent's state between steps. It does not save a position inside a step. When a step pauses for a person and later resumes, the framework can't jump back to the middle of your function, so it runs the step again from the top.

LangGraph says so in its interrupts docs (opens in a new tab): "The node restarts from the beginning of the node where the interrupt was called when resumed." Code above the interrupt() call runs once when the graph first reaches it and once more on every resume. The docs add that side effects before an interrupt should ideally be idempotent, and warn that a non-idempotent call there can create duplicate records.

That makes the cause easy to find. If an action happens twice and it sits above the line where the run waits for a person, the resume is repeating it. The same applies to a node that crashed after the action and is retried, and to a process restart that replays the step.

See it happen

This graph has one node. It emails the customer that the refund is under review, then waits for approval. The run is paused at the interrupt and resumed with a yes.

before.py

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

sent = []  # stands in for the mail provider

def send_email(to: str, body: str) -> None:
    sent.append((to, body))
    print(f"  email sent to {to}")

class State(TypedDict):
    decision: str

def review(state: State):
    send_email("dana@example.com", "Your refund is being reviewed")  # side effect BEFORE the interrupt
    answer = interrupt("approve the refund?")
    return {"decision": answer}

g = StateGraph(State)
g.add_node("review", review)
g.add_edge(START, "review")
g.add_edge("review", END)
app = g.compile(checkpointer=InMemorySaver())

cfg = {"configurable": {"thread_id": "ticket-77"}}
print("run:")
app.invoke({"decision": ""}, cfg)
print("resume with approval:")
app.invoke(Command(resume="yes"), cfg)
print("emails sent:", len(sent))
You should see
run:
  email sent to dana@example.com
resume with approval:
  email sent to dana@example.com
emails sent: 2

The email appears on both lines because the node ran twice. Nothing is broken in LangGraph here. The node did what the docs describe.

Three ways to keep it to one

All three are in the file below, next to the broken version, each run through the same pause and resume.

fixes.py

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command, interrupt
from langgraph.func import entrypoint, task

# A stand-in mail provider. Like many real ones, it keeps the first message sent under a key
# and answers a repeat with that same message.
inbox: dict[str, str] = {}
def send_email(to: str, body: str, key: str | None = None) -> str:
    k = key or f"nokey-{len(inbox)}"
    if key and k in inbox:
        return k
    inbox[k] = f"{to}: {body}"
    return k

class State(TypedDict):
    decision: str

def run_graph(name, node):
    inbox.clear()
    g = StateGraph(State)
    g.add_node("review", node)
    g.add_edge(START, "review")
    g.add_edge("review", END)
    app = g.compile(checkpointer=InMemorySaver())
    cfg = {"configurable": {"thread_id": name}}
    app.invoke({"decision": ""}, cfg)
    app.invoke(Command(resume="yes"), cfg)
    print(f"{name:<34} emails on file: {len(inbox)}")

# 0. the bug: effect, then interrupt
def effect_first(state):
    send_email("dana@example.com", "refund under review")
    return {"decision": interrupt("approve?")}
run_graph("effect before interrupt", effect_first)

# 1. effect after the interrupt: the code above the interrupt has no effects to repeat
def effect_after(state):
    answer = interrupt("approve?")
    send_email("dana@example.com", f"refund {answer}")
    return {"decision": answer}
run_graph("effect after interrupt", effect_after)

# 2. a key that is the same on every replay of this step
def keyed(state):
    send_email("dana@example.com", "refund under review", key="ticket-77:review-notice")
    return {"decision": interrupt("approve?")}
run_graph("effect before interrupt, with a key", keyed)

# 3. the effect in its own node: a finished node is not run again on resume
def run_split():
    inbox.clear()
    def notify(state):
        send_email("dana@example.com", "refund under review")
        return {}
    def approve(state):
        return {"decision": interrupt("approve?")}
    g = StateGraph(State)
    g.add_node("notify", notify); g.add_node("approve", approve)
    g.add_edge(START, "notify"); g.add_edge("notify", "approve"); g.add_edge("approve", END)
    app = g.compile(checkpointer=InMemorySaver())
    cfg = {"configurable": {"thread_id": "split"}}
    app.invoke({"decision": ""}, cfg)
    app.invoke(Command(resume="yes"), cfg)
    print(f"{'effect in its own node':<34} emails on file: {len(inbox)}")
run_split()

# 4. Functional API: the effect inside a @task. A finished task is replayed from the checkpoint.
def run_task(crash_after_send: bool, name: str):
    inbox.clear()
    state = {"first": True}
    @task
    def notify():
        send_email("dana@example.com", "refund under review")
        if crash_after_send and state["first"]:
            state["first"] = False
            raise RuntimeError("process died before the result was recorded")
        return "sent"
    @entrypoint(checkpointer=InMemorySaver())
    def flow(_: str):
        notify().result()
        return interrupt("approve?")
    cfg = {"configurable": {"thread_id": name}}
    try:
        flow.invoke("go", cfg)
    except RuntimeError:
        pass
    flow.invoke(None, cfg) if crash_after_send else None
    if not crash_after_send:
        flow.invoke(Command(resume="yes"), cfg)
    print(f"{name:<34} emails on file: {len(inbox)}")
run_task(False, "effect in a @task")
run_task(True, "@task, died before its result")
  • Put the effect after the interrupt. Nothing above the pause has a side effect, so there is nothing to repeat. This is the simplest fix and the right one when the action should wait for the answer anyway.
  • Give the effect its own node. A node that finished is not run again on resume, because the checkpoint is written after it. Use this when the email has to go out before the wait, as a notice does.
  • Pass a key that is the same on every replay. Build it from things that don't change, such as the thread id and the name of the step. A provider that honors an idempotency key keeps the first message and answers the repeat with it. Use this when you can't reorder the code, or the call lives inside a library.
You should see
effect before interrupt            emails on file: 2
effect after interrupt             emails on file: 1
effect before interrupt, with a key emails on file: 1
effect in its own node             emails on file: 1
effect in a @task                  emails on file: 1
@task, died before its result      emails on file: 2

Avoid a random key made inside the node, which is new on every replay, and avoid a hash of the arguments if the same message could legitimately go out twice. The retry guide has more on choosing keys.

Two nearby traps from the same docs page: a bare try/except around interrupt() swallows the pause, and a while True loop around it replays every earlier iteration on each resume.

Putting the effect in a task

LangGraph's answer for work with side effects is a @task. The functional API docs (opens in a new tab) say a finished task's result is replayed from the checkpoint instead of running again, and the example above shows it: the email in the task went out once.

The last line of the output is the catch, and the same docs say it: "A task that started but did not finish may run again on that resume." In that run the task sent the email and then raised before its result was recorded, which is what a process dying at that moment looks like. The email went out a second time. A task narrows the window and doesn't close it, so the task body still needs a key.

Durability modes

LangGraph can write checkpoints at exit, asynchronously or synchronously. It is tempting to read that setting as a safety level for side effects. It sets when state is saved. A recent issue on the LangGraph repo (opens in a new tab) asks for the docstrings to say exactly this, and a developer in the thread who counted effects across the three modes got the same count in each. A call whose reply was lost ran twice in all of them.

Test it

You don't need a model for this. Put a stand-in for the real service in the test, run the graph to the interrupt, resume it, and count what reached the stand-in. That is what fixes.py does, in under a hundred lines. Add a case that raises right after the effect, since that is the crash you can't see in a normal run. Keep the stand-in honest: if the real service ignores keys, the stand-in must too.

If you'd rather not build this

Durable-execution engines such as Temporal, Restate, DBOS and Inngest record each action's result and replay it instead of repeating it. They have the same cut-off window as the task above, so the receiving side still needs to recognize a repeat. Argentic is one more option: it journals each tool call and hands your tool a stable key for it, as the LangGraph guide shows. Whichever you pick, test with a kill between the action and the record.

What this doesn't cover

  • The code ran on Python 3.13 with langgraph 1.2.16 and the in-memory checkpointer. A database checkpointer saves the same state at the same points, but we did not run this against one. The JavaScript library has the same interrupt model in its docs, and we did not run it.
  • The mail provider is a stand-in that keeps the first message under a key. A real provider that ignores keys will send both.
  • A key protects only while the service remembers it. Keep your own record for anything that can wait days, as the retry guide shows.
  • None of these fixes makes an action happen exactly once. Moving the effect or giving it its own node stops the repeat on a normal resume. A key makes a repeat recognizable. A crash right after the effect can still repeat it.