← Back to Learning Hub

Time Travel and State History

LangGraphDurable ExecutionAdvanced20 min

By: Anacodic Team

TL;DR

Because a checkpointer saves a snapshot after every super-step, a thread is not just its current state — it is an addressable chain of past states. get_state_history(cfg) walks that chain newest first, every snapshot carries a checkpoint_id in its config, and passing that config back to invoke(None, ...) replays execution from that exact point. Call update_state() on a past checkpoint first and you fork: a new branch with a different value, run forward to a different outcome, with the original branch still intact. This gives you deterministic debugging and genuine counterfactuals ("what would this have cost at a 40% discount?"). The hard boundary: replay re-executes your nodes, so it is only reproducible to the extent your nodes are deterministic — any node that calls an LLM at nonzero temperature or reads the clock will produce a different replay, and nothing warns you.


Simple explanation + analogy

Most systems keep the present and throw away the path to it. When something goes wrong you get the wreckage and a log, and you reconstruct the story by inference.

Time travel in LangGraph is version control for a running program. The checkpoint chain is a commit history; each checkpoint is a commit with a hash. You can log it, check out any past commit, and re-run from there. Change a value at that point and run forward, and you have made a branch — the original history is still there, unmodified.

The analogy is close enough to be genuinely useful:

GitLangGraph
git logget_state_history(cfg)
commit hashcheckpoint_id in snapshot.config
git checkout <hash>invoke(None, snapshot.config)
commit + runupdate_state(...) then invoke(None, forked_cfg)
brancha fork on the same thread

Where it stops being like git, and this is the part that matters: checking out a commit in git gives you back exactly the files you had. Replaying a checkpoint in LangGraph re-runs your code. Git replays data; LangGraph replays execution. If your nodes are pure functions of state, those amount to the same thing. If a node calls an LLM, hits an API, or reads datetime.now(), they do not — and the reproducibility you thought you had is an illusion.


Diagram

  ONE RUN, checkpoints committed after every super-step
  ────────────────────────────────────────────────────────────────────
   ckpt#0            ckpt#1              ckpt#2                ckpt#3
   next=(__start__)  next=(pick_tier)    next=(set_discount)   next=(compute_total)
   {}                {price:1200}        {tier:gold}           {discount:15}
      │                  │                    │                     │
      └──────────────────┴────────────────────┴─────────────────────┘
                                                                    │
                                                             ckpt#4  next=()
                                                             {total: 1020.00}  ◀── original

  get_state_history() returns these NEWEST FIRST:
      [ckpt#4, ckpt#3, ckpt#2, ckpt#1, ckpt#0]


  REPLAY — same checkpoint_id, no edit: re-runs compute_total, same answer
  ────────────────────────────────────────────────────────────────────
   ckpt#3 ──▶ [compute_total] ──▶ total = 1200 * (1 - 15/100) = 1020.00   ✓ identical


  FORK — update_state() at ckpt#3, then run forward
  ────────────────────────────────────────────────────────────────────
   ckpt#3 {discount:15}
      │
      ├── original branch ──▶ total = 1020.00      ◀── still on the thread
      │
      └── update_state({discount: 40.0}) ──▶ ckpt#3'  {discount:40}
                                                │
                                                └──▶ [compute_total]
                                                     total = 1200 * 0.60
                                                           = 720.00       ◀── new branch

   Both 1020.00 and 720.00 now exist in the thread's history. Nothing was overwritten.

How it works (deep)

1. History is newest first, and next is how you navigate it

get_state_history(cfg) yields StateSnapshot objects in reverse chronological order. The finished state is first; the pre-START checkpoint is last.

Do not index into it. The reliable way to find a point is by what was about to run:

history = list(app.get_state_history(cfg))
before_total = next(h for h in history if h.next == ("compute_total",))

h.next == () identifies the finished state. h.next == ("some_node",) identifies the moment just before some_node executed. Selecting by position (history[1]) breaks the moment the graph gains a node or a fan-out changes the step count; selecting by next expresses the intent.

2. A checkpoint_id is an address, and it lives in .config

Each snapshot's .config is a full config dict with checkpoint_id added alongside thread_id. You do not construct it — you pass the snapshot's own config straight back:

replayed = app.invoke(None, before_total.config)

None as input means "do not seed new state, continue from what is saved". Passing a config with a checkpoint_id means "start from that point" rather than from the tip. Passing a config with only thread_id resumes from the latest checkpoint. That single difference is what separates replay from ordinary resumption.

3. Replay re-executes; it does not restore

This is the crux of the whole topic. Replaying from ckpt#3 does not paste in the old total. It runs compute_total again, on the state as of ckpt#3.

For a deterministic node the result is identical, which is what makes replay a debugging tool: you can put a breakpoint or a log line in the node that misbehaved and re-enter it with byte-identical inputs, as many times as you like, without re-running the twelve expensive steps before it.

For a nondeterministic node the result is not identical, and the framework neither knows nor warns. An LLM node at temperature 0.7 gives a different answer on replay. A node reading the current time takes a different branch. A node calling an external API sees changed data. Your "reproduction" quietly stops reproducing the bug.

There is a second consequence that costs real money: replay re-runs side effects. Replaying a node that sends an email sends the email again. This is the same at-least-once execution property that makes interrupt() re-run its node, and it demands the same discipline — replay-safe node bodies.

4. Forking with update_state

update_state(config, patch) writes a patch as if a node had produced it, creating a new checkpoint, and returns a config pointing at it:

forked_cfg = app.update_state(before_total.config, {"discount_pct": 40.0})
forked = app.invoke(None, forked_cfg)

Because the target config carried a checkpoint_id, the new checkpoint descends from that point rather than from the tip — that is what makes it a branch instead of an append. The original path is untouched, which the runnable example verifies by finding both totals in the thread's history afterwards.

Two cautions. The patch goes through your reducers, so patching a key with operator.add appends rather than replaces — a frequent surprise when someone tries to "correct" a message list and doubles it instead. And the patch bypasses node logic and validation entirely, so it is possible to write a state shape no node would ever produce, which then fails somewhere downstream with a confusing error.

5. What forking is genuinely good for

Counterfactuals on real runs. You have an expensive twelve-step pipeline and you want to know what the last two steps would have produced under a different assumption. Re-running the whole thing costs twelve steps and, if any early node is nondeterministic, does not even hold the earlier work constant. Forking at step ten runs exactly two nodes against provably identical prior state.

The same shape drives human correction in a review UI: a reviewer sees a bad extraction at step 4, patches it, and the pipeline re-runs from step 4 rather than from the beginning.

6. Reading history is cheap; keeping it is not

The chain is exactly the checkpoint chain, so its cost is the persistence cost — snapshots, not diffs, so a thread's storage is quadratic in its length. Forking adds branches to the same thread and therefore adds more. History is not free and nothing prunes it.

7. Where time travel stops working

It requires a durable checkpointer. InMemorySaver gives you history only until the process exits, so post-mortem debugging of a production incident needs SqliteSaver or PostgresSaver from the start — you cannot go back and wish you had persisted. And it only ever recovers state, never the outside world: forking a run that already charged a card does not un-charge it.


The math

The value of forking is the ratio of a full re-run to a partial one.

  k    = total super-steps in the run
  j    = step you fork at        (0 <= j <= k)
  t_s  = wall-clock per super-step
  c_s  = cost per super-step (tokens, API spend)

  full re-run       T_full = k * t_s          C_full = k * c_s
  fork at step j    T_fork = (k - j) * t_s    C_fork = (k - j) * c_s
  saving            1 - (k - j)/k = j/k

Reproducibility is the part worth modelling honestly. If each node is deterministic with probability p_i, an exact replay of the segment after the fork requires all of them to behave:

  P(exact replay) = product of p_i for i in (j .. k]

  all deterministic          p_i = 1     -> P = 1
  one LLM node at temp>0     p_i = 0     -> P = 0     (regardless of the others)

There is no partial credit. A single nondeterministic node in the replayed segment makes exact replay impossible.

Worked example

A k = 12 step pipeline, t_s = 4 s, c_s = $0.05, forking at j = 10.

  T_full = 12 * 4 s            = 48 s        C_full = 12 * 0.05 = $0.60
  T_fork = (12 - 10) * 4 s     =  8 s        C_fork =  2 * 0.05 = $0.10
  saving = 10 / 12             = 83%

Now the reproducibility check on those two replayed steps. If both are deterministic, P = 1. If step 11 is an LLM call at temperature 0.7, P = 0 — the 83% saving is real but you are no longer comparing like with like, because the fork changed two things: your patched value and the model's sampling. To make a counterfactual mean anything, pin temperature to 0 and seed anything random in the replayed segment.

The runnable example below has k = 4, all nodes pure arithmetic, so P = 1 and the replayed total matches the original exactly — which is asserted rather than asserted-by-hope.


Real code

A quote pipeline: run it, walk its history, replay a step deterministically, then fork to a counterfactual discount.

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

class Quote(TypedDict):
    list_price: float
    tier: str
    discount_pct: float
    total: float

def pick_tier(state: Quote) -> dict:
    return {"tier": "gold" if state["list_price"] >= 1000 else "standard"}

def set_discount(state: Quote) -> dict:
    return {"discount_pct": 15.0 if state["tier"] == "gold" else 5.0}

def compute_total(state: Quote) -> dict:
    return {"total": round(state["list_price"] * (1 - state["discount_pct"] / 100), 2)}

g = StateGraph(Quote)
for n, f in [("pick_tier", pick_tier), ("set_discount", set_discount), ("compute_total", compute_total)]:
    g.add_node(n, f)
g.add_edge(START, "pick_tier"); g.add_edge("pick_tier", "set_discount")
g.add_edge("set_discount", "compute_total"); g.add_edge("compute_total", END)
app = g.compile(checkpointer=InMemorySaver())

cfg = {"configurable": {"thread_id": "quote-1"}}
original = app.invoke({"list_price": 1200.0, "tier": "", "discount_pct": 0.0, "total": 0.0}, cfg)
print("original total:", original["total"])

# History comes back NEWEST FIRST. next=() is the finished state.
history = list(app.get_state_history(cfg))
print("checkpoints:", len(history))
for h in history:
    print(f"  next={str(h.next):18s} discount={h.values.get('discount_pct')} total={h.values.get('total')}")

# Find the checkpoint taken just BEFORE compute_total ran.
before_total = next(h for h in history if h.next == ("compute_total",))
print("replay from:", before_total.next, "discount was", before_total.values["discount_pct"])

# 1. REPLAY — same checkpoint_id, no changes: deterministic re-run, same answer.
replayed = app.invoke(None, before_total.config)
print("replayed total:", replayed["total"])
assert replayed["total"] == original["total"], "replay is deterministic"

# 2. FORK — write a different discount at that point, creating a new branch.
forked_cfg = app.update_state(before_total.config, {"discount_pct": 40.0})
forked = app.invoke(None, forked_cfg)
print("forked total  :", forked["total"])

assert original["total"] == 1020.0, original["total"]
assert forked["total"] == 720.0, forked["total"]

# The fork did NOT overwrite history: the original run is still on the thread.
totals = {h.values.get("total") for h in app.get_state_history(cfg) if h.values.get("total")}
print("totals present on thread:", sorted(totals))
assert 1020.0 in totals and 720.0 in totals, "both branches coexist"
print("OK: replayed identically, forked to a new outcome, original preserved")

# Output:
#   original total: 1020.0
#   checkpoints: 5
#     next=()                 discount=15.0 total=1020.0
#     next=('compute_total',) discount=15.0 total=0.0
#     next=('set_discount',)  discount=0.0 total=0.0
#     next=('pick_tier',)     discount=0.0 total=0.0
#     next=('__start__',)     discount=None total=None
#   replay from: ('compute_total',) discount was 15.0
#   replayed total: 1020.0
#   forked total  : 720.0
#   totals present on thread: [720.0, 1020.0]
#   OK: replayed identically, forked to a new outcome, original preserved

The printed history is worth studying line by line, because it shows the mechanism plainly. Reading upward from the bottom you see discount go from None to 0.0 to 15.0 while total stays 0.0 until the last step — each row is a real commit, not a reconstruction. The snapshot with next=('compute_total',) has discount=15.0 and total=0.0: the discount decision had been committed, the total had not yet been computed. That is exactly the seam to fork at.

And totals present on thread: [720.0, 1020.0] is the proof that forking branches rather than overwrites. Both outcomes are queryable on one thread afterwards.


Real-world example

A load-forecasting pipeline runs a panel of models, then calibrates prediction intervals from their disagreement. Calibration is the last stage and the one with a tunable parameter — the target coverage level.

The question that came up in review was ordinary: what would the intervals have looked like at 95% coverage instead of 90%? The original answer was to re-run the pipeline, which took most of an hour because fitting the panel dominates the cost, and it had to be repeated for every coverage level anyone wanted to see.

Forking replaced that. The run's history was walked to the checkpoint with next=("calibrate",) — panel fitted, residuals computed, nothing calibrated — and each coverage level became an update_state at that point plus a single node execution. Seconds instead of an hour, and every variant was calibrated against provably identical model outputs, which the full re-run could not guarantee.

That last point turned out to be the real benefit. An early attempt at the comparison had re-run the whole pipeline per coverage level, and the intervals differed for two reasons at once: the coverage change and the panel having refit slightly differently. The numbers were not comparable and a plausible-looking conclusion had to be thrown out. Forking holds everything before the fork point exactly constant, which is what makes the comparison a comparison.

The failure that taught the most was a fork on the wrong key. Someone patched an accumulating residuals list, forgetting that update_state runs patches through reducers — the key had operator.add, so the patch appended and the list silently doubled. Coverage came out near-perfect and completely wrong. If a key has a reducer, a fork that means to replace has to work with that reducer, not against it.


Interview questions companies actually ask

1. Where does the ability to time travel come from? [easy] From checkpointing. Since a snapshot is committed after every super-step, the thread is an addressable chain of past states rather than just a current one. Time travel is not a separate feature — it is what you can do once history is durable and each entry has an id.

2. What order does get_state_history return, and how should you select a point in it? [medium] Newest first. Select by snapshot.next — the tuple of nodes that were about to run — not by index, because positional indexing breaks whenever the graph gains a node or a fan-out changes the step count. next == () is the finished state; next == ("x",) is the moment before x ran.

3. What is the difference between resuming and replaying? [medium] Both call invoke(None, cfg). If the config has only thread_id, you resume from the latest checkpoint. If it also has a checkpoint_id — because you passed a historical snapshot's own .config — you replay from that point. The presence of checkpoint_id is the whole distinction.

4. Does replaying restore the old result or recompute it? [hard] It recomputes. Replay re-executes the nodes from that checkpoint against the saved state; it does not paste back stored outputs. That is what makes it useful for debugging — you can add logging and re-enter the failing node with identical inputs — and also what makes it unsafe for nodes with side effects, since those effects happen again.

5. Why might a replay not reproduce the original run? [hard] Because your nodes are not necessarily deterministic. An LLM at nonzero temperature, a clock read, a random seed, or an external API whose data changed will all diverge, and nothing warns you. Exact replay of a segment requires every node in it to be deterministic — there is no partial credit — so pin temperature to 0 and seed randomness if you need reproducibility.

6. How do you fork a run, and why does it branch rather than append? [medium] Call update_state(historical_snapshot.config, patch), which returns a config for the new checkpoint, then invoke(None, that_config). It branches because the config you patched carried a checkpoint_id, so the new checkpoint descends from that historical point rather than from the tip. The original path remains queryable.

7. What is the trap when forking a key that has a reducer? [hard] update_state patches pass through reducers, so patching a key with operator.add appends instead of replacing. Trying to correct a list by patching it doubles it instead. You have to work with the reducer's semantics — often by replacing the whole structure in a way the reducer handles, or by not putting correctable values behind an accumulating reducer.

8. What is the cost argument for forking over re-running? [medium] Forking at step j of k runs k-j steps instead of k, saving j/k of time and spend — 83% for a fork at step 10 of 12. The bigger benefit for analysis is that everything before the fork is held byte-identical, so a counterfactual compares one changed variable rather than two.

9. What does time travel not give you? [medium] Any control over the outside world. It recovers state, so forking a run that already sent an email or charged a card does not undo either. It also needs a durable checkpointer to exist at all — you cannot retroactively obtain history for a production incident if the run used InMemorySaver.


When to use / tradeoffs

Reach for time travel when:

  • Debugging a failure deep in an expensive pipeline — replay the one bad node with identical inputs.
  • Answering counterfactuals on a real run, with everything before the fork held constant.
  • Building a review UI where a human corrects an intermediate value and the run continues from there.
  • Auditing: showing exactly what the state was when a decision was made.

Do NOT use when:

SituationWhy it breaksUse instead
Nodes in the replayed segment are nondeterministicReplay diverges silently; no warningPin temperature to 0, seed randomness
Replayed nodes have external side effectsEffects fire again on every replayMake them idempotent, or replay only pure segments
Using InMemorySaver and needing post-mortem historyHistory dies with the processSqliteSaver / PostgresSaver from the start
Forking a key with an accumulating reducerThe patch appends instead of replacingRestructure the key, or patch with the reducer's semantics
Very long threads, heavy forkingSnapshot storage is quadratic and branches add moreTrim history; retention policy
Trying to undo a real-world actionOnly state is versioned, not the worldCompensating action

Honest limits. The reproducibility model is the honest part of this article and the reason to be careful with the rest: P(exact replay) = 1 only for pure nodes, and almost every interesting LangGraph node calls a model. In practice most replays are approximate, which is fine for "why did this branch get taken" and misleading for "the fork changed the outcome by 12%", because you cannot separate your intervention from the model's sampling unless you have pinned it. The cost model is optimistic too — it assumes per-step cost is uniform, while forking late in a pipeline whose expensive stage is early is exactly the case where the saving is largest, and forking late in one whose expensive stage is last saves almost nothing. Storage is the quiet cost: history is snapshots rather than diffs, so it is quadratic in thread length before you fork at all, and each branch adds more with nothing pruning it. And versioned state is not a versioned world — replay recovers what your program knew, never what it did.


  • Time travel is a consequence of checkpointing: every super-step commits an addressable snapshot.
  • get_state_history(cfg) walks the chain newest first; select a point by snapshot.next, never by index.
  • A config carrying a checkpoint_id replays from that point; one with only thread_id resumes from the tip.
  • Replay re-executes nodes rather than restoring outputs — great for debugging, dangerous with side effects.
  • Exact replay needs every node in the segment to be deterministic; one LLM at temp > 0 breaks it, silently.
  • update_state() on a historical checkpoint forks, and patches pass through reducers — the classic fork bug.
  • Forking at step j of k saves j/k, and holds everything before the fork constant, which is what makes a counterfactual valid.

Related articles in this module (6-6):

Related elsewhere:

Sources: LangGraph docs: time travel · LangGraph docs: persistence and checkpoints · LangGraph reference: get_state_history, update_state · Verified against langgraph 1.2.8; all output above is from an actual run.