Replay is not a debugging convenience. In an agent graph, replay is a production side-effect boundary. If a node can run again from a checkpoint, every LLM call, API request, tool write, approval prompt, queue publish, and database mutation downstream of that checkpoint needs a contract for what happens the second time.
That is the decision rule for this week’s Graph Engineering post: treat every replayable node as at-least-once execution until you prove otherwise. If the node touches the outside world, wrap it with idempotency keys, effect receipts, and a recovery policy before you trust replay in production.
LangGraph makes this boundary unusually visible. Its Graph API describes a graph as State, Nodes, and Edges: state is the shared snapshot, nodes perform computation or side effects, and edges choose what runs next (LangGraph Graph API overview). That model is clean, but the production question hides inside the word “side effects.” Pure computation can replay freely. Side effects cannot.
The replay contract starts at the checkpoint
LangGraph persistence separates two ideas that many teams blur together. Checkpointers persist a thread’s graph state as checkpoints for short-term, thread-scoped memory: conversation continuity, human-in-the-loop workflows, time travel, and fault tolerance. Stores persist application-defined data outside graph state for long-term, cross-thread memory (LangGraph Persistence).
That distinction gives us a useful architecture rule:
| Runtime object | Production meaning | Replay risk |
|---|---|---|
| Graph state | The current orchestration snapshot | Shape changes or stale branch assumptions |
| Checkpoint | A resumable point in execution history | Downstream nodes may execute again |
| Store | Durable application memory outside the thread | Cross-thread writes can duplicate or conflict |
| External system | API, database, queue, ticket, email, payment, deploy | Real-world effects may repeat |
The trap is assuming checkpointed execution means exactly-once execution. It does not. LangGraph time travel supports replay and fork through checkpoints. The official docs state that nodes before the checkpoint are not re-executed because their results are already saved, while nodes after the checkpoint re-execute, including LLM calls, API requests, and interrupts, which may produce different results (LangGraph Time Travel).
That is a feature. It lets you retry a bad path, fork a state, inspect an alternative, or recover from failure. It is also the reason graph engineers need effect boundaries. Replay asks: “What if this node runs again?” Production asks the sharper version: “What if this node partially succeeded, the checkpoint boundary is ambiguous, and the operator retries from the last safe point?”
A graph node is either pure, observed, or effective
I like classifying every node in a production graph into one of three categories.
| Node class | Examples | Replay posture |
|---|---|---|
| Pure node | Prompt assembly, deterministic validation, state routing | Safe to replay if inputs and code are compatible |
| Observed node | LLM call, retrieval query, scoring request, search read | Replayable, but results may differ and need trace linkage |
| Effective node | Ticket creation, database write, Slack send, deploy command, approval action | Must use idempotency and receipts |
The important move is not to ban side effects. Agents are useful because they affect systems. The move is to make side effects explicit at the graph boundary.
A pure node can be retried with ordinary tests. An observed node needs trace IDs and cached evidence if reproducibility matters. An effective node needs an idempotency key, a durable receipt, and a policy for “already done.” Without those, replay converts a recovery mechanism into a duplication bug.
Here is the minimal wrapper I want around any effective node:
from dataclasses import dataclass
from typing import Callable, Literal
@dataclass(frozen=True)
class EffectReceipt:
key: str
status: Literal["created", "already_done"]
external_id: str
class EffectLedger:
def __init__(self):
self._receipts: dict[str, EffectReceipt] = {}
def run_once(
self,
*,
key: str,
perform: Callable[[], str],
) -> EffectReceipt:
if key in self._receipts:
return EffectReceipt(key, "already_done", self._receipts[key].external_id)
external_id = perform()
receipt = EffectReceipt(key, "created", external_id)
self._receipts[key] = receipt
return receipt
In a real system, the ledger is not an in-memory dictionary. It is a database table, durable queue, workflow activity record, or API-native idempotency record. The shape matters more than the storage: the graph must be able to ask, “Have I already performed this effect for this thread, node, state version, and business object?”
A practical idempotency key often looks like this:
{graph_name}:{thread_id}:{node_name}:{effect_name}:{business_id}:{state_version}
Do not use a random UUID generated inside the node as the only key. On replay, the node may generate a different UUID and defeat the entire purpose. The key should be derived from stable graph context and the business object being changed.
Interrupts make the same rule human-visible
Human-in-the-loop graphs make effect boundaries easier to understand because the side effect is obvious: the graph pauses and waits for external input. LangGraph interrupts pause execution, save graph state through the persistence layer, and resume by re-invoking the graph with Command, which becomes the return value of interrupt() inside the node (LangGraph Interrupts).
The docs also warn that side effects called before an interrupt must be idempotent (LangGraph Interrupts). That warning is the whole production lesson in miniature. If a node sends an approval request and then calls interrupt(), replaying or resuming incorrectly can send the approval twice unless the send is guarded by a stable key and receipt.
A safer shape is to split the node into two responsibilities:
flowchart LR
A["Build approval payload"] --> B["Record request intent"]
B --> C["Send approval once"]
C --> D["Interrupt for decision"]
D --> E["Apply approved change"]
classDef safe fill:#102033,stroke:#7fc8ff,color:#f8fbff;
class A,B,C,D,E safe;
The approval payload is pure. The request intent is durable. The send operation is idempotent. The interrupt carries the request ID rather than an implicit promise that the notification has been sent exactly once. The final apply step uses the human decision plus the same request ID.
That design may feel heavier than a single node that sends a message and pauses. It is heavier. It is also debuggable at 2 AM.
Replay is a test surface, not only an operator tool
The best graph teams do not discover replay behavior during incidents. They test it.
For every effective node, add a replay test with three assertions:
- The external effect is created once.
- Replaying from the relevant checkpoint returns the existing receipt.
- The graph state after replay is equivalent to the intended successful state.
A small pytest-style sketch looks like this:
def test_ticket_node_is_replay_safe(effect_ledger, fake_ticket_api):
state = {"thread_id": "t-123", "incident_id": "inc-9"}
first = create_ticket_node(state, ledger=effect_ledger, api=fake_ticket_api)
second = create_ticket_node(state, ledger=effect_ledger, api=fake_ticket_api)
assert fake_ticket_api.created_count == 1
assert first["ticket_id"] == second["ticket_id"]
assert second["effect_status"] == "already_done"
This is not a substitute for integration tests against the real graph checkpointer. It is the unit of design pressure. If the node cannot be tested this way, it probably does not have an effect boundary yet.
The tradeoff: velocity versus operability
The counterargument is real: strict effect boundaries slow development. A prototype graph can move faster by letting nodes call tools directly and trusting checkpoints to recover the rest. For demos, that is fine. For production agent systems, the cost moves from development time to incident time.
Without effect boundaries, operators face bad choices:
- replay and risk duplicate external changes,
- avoid replay and lose the best recovery tool,
- patch state manually and create invisible history,
- or disable the automation path for every ambiguous failure.
With effect boundaries, replay becomes an operational primitive. You can retry from a checkpoint, inspect receipts, fork state for analysis, and explain what did or did not touch the outside world.
This is where mature workflow systems are useful analogues. Temporal’s documentation treats workflow definitions as code that defines execution and calls out deterministic constraints, versioning, and handling non-deterministic behavior (Temporal Workflow Definition). Agent graphs are not identical to Temporal workflows, but the production pressure rhymes: once execution history can resume, code changes and side effects become part of the runtime contract.
The review checklist I use
Before shipping a replayable agent graph, I want answers to these questions:
- Which nodes are pure, observed, and effective?
- Which checkpoint can an operator safely replay from?
- Which downstream nodes may call an LLM, API, tool, queue, or database again?
- Does each effective node have a stable idempotency key derived from graph and business context?
- Where is the effect receipt stored, and can the graph load it after restart?
- What does the node return when the effect already happened?
- What is the compensation path if the effect happened but the receipt write failed?
- Do tests replay from the checkpoint that production operators will actually use?
- Are interrupt payloads request references rather than hidden side-effect assumptions?
- Can logs and traces tell the difference between “created,” “already done,” and “skipped”?
The most common mistake is only answering these for obvious writes. LLM calls, retrieval calls, and approval messages deserve attention too. They may not mutate your database, but they can change user-visible outcomes, spend budget, or create duplicated human work.
The engineering idea
Graph replay is a reliability feature only after effects have boundaries. Before that, replay is just a second chance to do the wrong thing twice.
So the production rule is simple: checkpoint the graph, but receipt the world. Let the graph runtime remember where it was. Make every external system remember whether the graph has already touched it. That separation turns replay from a scary operator button into a normal part of the agent runtime.