Human-in-the-Loop in LangGraph: Where Should interrupt() Go? Six do/don't Pairs

Six do/don't criteria for human approval in LangGraph agents.
Based on a Reddit practice note, this article summarizes six key criteria for adding human approval to agents in LangGraph using do/don't pairs: pause only before irreversible steps, show actual effects in the interrupt payload, place side effects after interrupt(), persist with a checkpointer and stable thread_id, track rejections and sampling instead of approval rate, and make rejection as cheap as approval. It also highlights two open questions: interrupt() vs. interrupt_before, and handling long waits and timeouts.

When adding human approval to an agent in LangGraph, the hard part is rarely calling the API—it's deciding where the approval points should go. A practice note on Reddit uses do/don't pairs to lay out six directly usable criteria. The author also notes that this is based on an understanding of the current API, and specific versions should still be confirmed against the documentation. Below, we walk through each point from the original post.
1. Don't pause before every node—only block steps that are hard to undo
The first pair is explicit: don't set a pause before every node; only pause before steps that are hard to undo and won't reveal their own errors, such as issuing refunds, deleting records, or sending emails. Discarding a draft reply costs little and isn't worth blocking.
The underlying cost is attention. If every step requires approval, reviewers will gradually learn to click approve without reading. The value of an approval step comes from its scarcity; once it becomes ubiquitous, it degenerates into a formality. Reserving approval for operations with real external impact is the precondition for making human intervention meaningful.
2. Don't let the model explain what it's going to do—put the actual effect in the interrupt payload
The second pair addresses what the approval interface displays. Don't show the model's explanation of its own plan; instead, put the actual effect into the interrupt payload: the action name, target record, amount, and, where available, a dry-run or diff. The original reasoning is sharp—a well-written summary paired with a bad action still reads smoothly; but the effect itself usually doesn't lie.
The code structure given in the original looks roughly like this:
from langgraph.types import interrupt
def issue_credit(state):
decision = interrupt({
"action": "issue_credit",
"account_id": state["account_id"],
"amount": state["amount"],
"reversible": False,
})
if decision != "approve":
return {"status": "rejected"}
# perform the side effect only after approval
...
Note that the payload includes structured fields like reversible: False; the reviewer sees verifiable parameters rather than a self-description. This is far more reliable than having the model write "I will issue a credit to account X."
3. Side effects must come after interrupt()
The third pair is the most technical one. Don't perform side effects in the same node before calling interrupt(); put the actual work after it. The author's understanding is that when the graph resumes, the node is re-executed from the start, so any code before interrupt() may run twice.
There are two ways to handle this—either make the code before interrupt() free of side effects, or make the operation idempotent. Ignoring this can lead to duplicate charges at best, or duplicate emails at worst. It's also the most common pitfall when first integrating human approval, because it's invisible during single-step debugging and only surfaces upon actual resumption.
Checkpoint and interrupt recovery mechanism in LangGraph
LangGraph's durable execution model is based on "checkpoints." After each step of graph execution, the checkpointer saves a state snapshot (checkpoint). When interrupt() is called, the current node throws an internal signal, and the graph enters an interrupted state—but the saved checkpoint points to the state before that node began execution. When an external approval signal arrives and the graph is resumed, LangGraph re-schedules the node from the most recent checkpoint, and the node function executes from the beginning, not from the point of the interrupt() call. This is why code before interrupt() runs twice: resumption isn't "continuation," it's "replay." Understanding this is key to diagnosing duplicate side effects and is the fundamental reason for designing idempotent operations or deferring side effects.
4. Don't forget persistence: a checkpointer and a stable thread_id are both essential
The fourth pair concerns how the graph is compiled. Don't forget persistence; compile the graph with a checkpointer and pass a stable thread_id, because the run needs to be saved during the wait so it can be retrieved upon resumption. If approval might take hours or even days, use a checkpointer that is truly backed by a database.
This constraint directly determines the shape of the approval flow. An in-memory checkpointer is fine for development and short waits, but a multi-day approval will be lost entirely if the process restarts. The original uses "how long can approval wait" as the criterion for choosing a checkpointer, which is a very pragmatic dividing line.
Checkpointer types and the role of thread_id
In LangGraph, the checkpointer determines when and on what medium the graph state is saved. Common options include MemorySaver (in-process memory, suitable only for development and short demos), SqliteSaver (local file, suitable for single-machine persistence), and savers based on databases like Postgres (suitable for production and cross-process, multi-day waits). thread_id is the key to restoring state: the thread_id passed on each graph invocation identifies an independent execution thread, and the checkpointer stores and retrieves state snapshots by thread_id. If the thread_id is not stable (e.g., randomly generated each run), the corresponding checkpoint cannot be found upon resumption, and the approval flow breaks. Therefore, "compile the graph with a checkpointer" and "pass a stable thread_id" are two sides of the same coin—both are indispensable.
5. Approval rate is not a success metric—look at rejections and sampling
The fifth pair is easy to overlook: don't treat the approval pass rate as a success metric; track rejections and sampled reviews, e.g., by sampling from LangSmith traces or your own logs.
The original logic is that a gate that never rejects might mean the agent is genuinely doing well, or it might mean the review isn't working at all—and the number of approvals alone can't distinguish the two. Only rejection samples and spot-check records can show whether the gate has value. This is also the key step in turning human approval from "process decoration" into a "measurable control point."
6. One approver isn't enough, and rejection should be as cheap as approval
The last pair concerns approval authority design. Don't assume one approver is enough for everything; make rejection cost the same single click as approval, and decide in advance who has the authority to say no and what happens after a no—retry, have the reviewer modify it, or stop entirely.
If rejecting is more cumbersome than approving, reviewers will tend to let things through, and approval loses its meaning. And if "what happens after rejection" isn't predefined, the flow will get stuck at the first real rejection. Both are decisions that should be made at the design stage, not patched in after launch.
Open questions left by the original
The author ends with two questions they don't yet have answers to, which are equally valuable for anyone building a similar flow: First, should you use interrupt() inside a tool node, or a static interrupt_before on the node? What led you to that choice? Second, how should approvals that arrive hours later, and timeout situations, be handled?
These two questions point to control granularity and the time dimension, respectively. The former affects how the approval point couples with tool logic; the latter affects the state design for waiting, timeouts, and retries. The original doesn't provide conclusions, but laying out the questions itself is valuable—they cover exactly the parts beyond the do/don't list that still require business-specific judgment.
The common thread across the entire set of pairs can be summed up in one sentence: approval points should be placed where side effects are real, irreversible, and the reviewer can understand the actual effect; everything else can be left to automation and post-hoc sampling.
Trade-offs between interrupt_before and interrupt()
The first open question mentioned at the end of the original concerns the two interruption methods LangGraph provides. interrupt_before is a static breakpoint declared at graph compile time, pausing before the specified node executes—suitable for scenarios with fixed approval points and simple logic. In contrast, calling interrupt() inside a node is dynamic: it can put actual parameters (such as amounts, target records) into the payload and tightly couple the pause point with business logic. Static breakpoints cannot carry runtime data, and the node executes as usual upon resumption; dynamic interrupt() allows the reviewer to see the specific effect and influence subsequent branches. Choosing between them is essentially a trade-off between "configurability of approval points" and "richness of approval information."
One-line Summary
Six do/don't criteria and design considerations for human approval in LangGraph.
Paragraph Summary
Based on a Reddit practice note, this article summarizes six key criteria for adding human approval to agents in LangGraph using do/don't pairs: only pause before steps that are hard to undo and won't reveal errors; the interrupt payload should show actual effects, not the model's self-description; side effects must come after interrupt() to avoid duplicate execution on resumption; persistence requires a checkpointer and a stable thread_id; approval pass rate is not a success metric—track rejections and sampling; and approval authority design should make rejection as low-cost as approval. The article also notes two open questions: the trade-off between interrupt() and interrupt_before, and handling long waits and timeouts.
Related articles

Building an AI Agent with Claude Code from Scratch: From Workspace to Cloud Autonomy
No coding required: build an AI Agent in four steps with the Claude Code desktop app—from workspace setup and Agent Designer to Skills, Memory, and cloud autonomy.

No Python Required: How Cursor AI Coding Is Reshaping Automated Testing and Test Development
Test engineers can do automated testing without learning Python? This article examines how Cursor and other AI coding tools empower functional testing, automation scripts, test platform development, and performance tuning, compares the cost and access restrictions of mainstream domestic and foreign tools, and offers capability transformation advice for testers.

Nearly 5,000 Affected! AI Coding Plugin Found with Backdoor, Officials Urgently Remove It
Claude Code's VS Code extension version 2.1.313 was maliciously tampered with, implanting a backdoor for remote login and persistent control, potentially affecting up to 5,000 users. Officials have removed the malicious version; this article details the attack method and cleanup steps.