Agent frameworks
Ren Okabe10 min read172 views

How to test whether a LangGraph DeltaValue reducer survives a resume

On @langchain/langgraph 1.4.19 a DeltaValue field replays checkpointed writes through your reducer in larger batches than your graph produced. If the reducer reads the batching, the resumed state is not the state you ran, and nothing errors. Three runnable scripts: a ten-line invariance check, a run where six turns become one, and a sweep showing the damage sits in the tail.

Flat cream printed schematic with two horizontal tracks. The upper track shows six identical grey rectangular markers, each enclosed in its own box and linked in a row. Thin connector lines run down to the lower track, where the same six markers sit fused together inside one single long bracket.
Flat cream printed schematic with two horizontal tracks. The upper track shows six identical grey rectangular markers, each enclosed in its own box and linked in a row. Thin connector lines run down to the lower track, where the same six markers sit fused together inside one single long bracket.
On this page

Quick Answer (October 2026). On @langchain/langgraph 1.4.19 a DeltaValue field does not store your accumulated value in every checkpoint. It stores periodic snapshots and replays the writes in between through your reducer, and it is allowed to replay them in larger batches than your graph produced. If your reducer treats each call as one step, the replayed state is not the state your run produced, nothing raises an error, and a node that resumes the thread reads the regrouped version. Below are three scripts that show it on your own install: a ten-line check that catches the reducer before you deploy it, a run where six turns become one, and a sweep showing the corruption is confined to the writes since the last snapshot.

LangChain TypeScript Node.js Zod npm

What DeltaValue actually stores

A normal reducer field in LangGraph keeps the whole accumulated value in each checkpoint blob. For a long message history that means re-serialising the entire list on every superstep, which is the write amplification DeltaValue exists to remove.

DeltaValue instead writes a snapshot every so often and, between snapshots, only the per-step writes. Reading the state back means walking the ancestor writes and folding them through your reducer again. That is the whole trick, and it is a good trick. It also means your reducer is no longer called once per step. It is called however the replay decides to batch things.

The SDK is explicit about the obligation this puts on you. The type declaration for the reducer option on DeltaValue says this:

LangGraph, DeltaValueInitBase.reducer: "Reducers must be deterministic and batching-invariant (associative across folds), because {@link DeltaChannel} replays checkpointed writes in larger batches than they were originally produced: reducer(reducer(state, xs), ys) === reducer(state, xs.concat(ys))."

That is the sentence as shipped in node_modules/@langchain/langgraph/dist/state/values/delta.d.ts, including its JSDoc link markup. The channel itself carries the same requirement in different words, and adds that a reducer which is not associative should use BinaryOperatorAggregate instead. I have abridged that second sentence rather than quoting it in full, because the original joins its two clauses with an em dash and this site does not print one.

The requirement is real, the SDK honours it in its own code, and nothing checks yours.

install

bash
npm init -y
npm pkg set type=module
npm install --save-exact @langchain/langgraph@1.4.19 zod@4.6.5

Note the zod range. LangGraph 1.4.19 peer-requires zod@^3.25.32 || ^4.2.0, so a 4.1.x pin fails to resolve. Everything below ran on langgraph 1.4.19, zod 4.6.5, Node v24.8.0. Nothing here calls a model or needs an API key, because a graph node is a plain function.

check the reducer before you ship it

The contract is a property, so you can test it as one. Split the same writes at every boundary, fold them both ways, and compare.

js
// Does a DeltaValue reducer survive being replayed in a different batching?
// Contract from the SDK: reducer(reducer(state, xs), ys) must equal
// reducer(state, xs.concat(ys)) for every split of the same writes.

function isBatchingInvariant(reducer, seed, samples) {
  const cuts = samples.map(function (_, i) { return i; }).slice(1);
  for (const cut of cuts) {
    const xs = samples.slice(0, cut);
    const ys = samples.slice(cut);
    const twoStep = reducer(reducer(seed(), xs), ys);
    const oneStep = reducer(seed(), xs.concat(ys));
    if (JSON.stringify(twoStep) !== JSON.stringify(oneStep)) {
      return { ok: false, cut: cut, twoStep: twoStep, oneStep: oneStep };
    }
  }
  return { ok: true };
}

function appendFlat(current, writes) { return [...current, ...writes]; }
function groupPerCall(current, writes) { return [...current, writes]; }
function trimTail(current, writes) { return [...current, ...writes].slice(-3); }
function countPerCall(current, writes) { return [...current, writes.length]; }

function seed() { return []; }
const samples = ["a", "b", "c", "d"];

const cases = [
  ["appendFlat", appendFlat],
  ["groupPerCall", groupPerCall],
  ["trimTail", trimTail],
  ["countPerCall", countPerCall],
];

for (const pair of cases) {
  const r = isBatchingInvariant(pair[1], seed, samples);
  if (r.ok) {
    console.log(pair[0].padEnd(14) + "batching-invariant, safe for DeltaValue");
  } else {
    console.log(pair[0].padEnd(14) + "NOT invariant at cut " + String(r.cut));
    console.log("".padEnd(14) + "  replayed in two batches " + JSON.stringify(r.twoStep));
    console.log("".padEnd(14) + "  replayed in one batch   " + JSON.stringify(r.oneStep));
  }
}
text
appendFlat    batching-invariant, safe for DeltaValue
groupPerCall  NOT invariant at cut 1
                replayed in two batches [["a"],["b","c","d"]]
                replayed in one batch   [["a","b","c","d"]]
trimTail      batching-invariant, safe for DeltaValue
countPerCall  NOT invariant at cut 1
                replayed in two batches [1,3]
                replayed in one batch   [4]

Two of these four are unsafe, and the interesting part is which two.

groupPerCall appends the batch itself, so the shape of the state records how the writes arrived. countPerCall records how many arrived. Both read the batching, so both change when the batching changes.

trimTail is the one worth pausing on. It concatenates and then keeps only the last three, which looks like exactly the sort of history-trimming that would be sensitive to batching, and it is not. Trimming a suffix commutes with concatenation, so it is safe. You cannot eyeball this property reliably, which is the argument for the test rather than for a rule of thumb.

watch a run disagree with itself

This builds the same six-step graph twice, once with the safe reducer and once with the grouping one, then resumes the thread and has the resuming node report what it was handed.

js
import { z } from "zod";
import { StateSchema, StateGraph, DeltaValue, MemorySaver, START, END } from "@langchain/langgraph";

const STEPS = 6;

function appendFlat(current, writes) { return [...current, ...writes]; }
function groupPerCall(current, writes) { return [...current, writes]; }

function schemaFor(reducer) {
  return new StateSchema({
    turns: new DeltaValue(z.array(z.any()).default(function () { return []; }), {
      inputSchema: z.string(),
      reducer: reducer,
    }),
    n: z.number().default(0),
  });
}

function writer(State, saver) {
  return new StateGraph(State)
    .addNode("step", function (s) {
      return { turns: "w" + String(s.n + 1), n: s.n + 1 };
    })
    .addEdge(START, "step")
    .addConditionalEdges("step", function (s) {
      return s.n === STEPS ? END : "step";
    }, ["step", END])
    .compile({ checkpointer: saver });
}

async function compare(label, reducer) {
  const State = schemaFor(reducer);
  const saver = new MemorySaver();
  const cfg = { configurable: { thread_id: "demo" } };

  const live = await writer(State, saver).invoke({}, cfg);

  let seenOnResume = null;
  const resumed = new StateGraph(State)
    .addNode("step", function (s) {
      seenOnResume = JSON.parse(JSON.stringify(s.turns));
      return { n: s.n };
    })
    .addEdge(START, "step")
    .addEdge("step", END)
    .compile({ checkpointer: saver });
  await resumed.invoke({}, cfg);

  const tuple = await saver.getTuple(cfg);
  const stored = Object.prototype.hasOwnProperty.call(tuple.checkpoint.channel_values, "turns");

  console.log(label);
  console.log("  live run saw      " + JSON.stringify(live.turns));
  console.log("  resumed node saw  " + JSON.stringify(seenOnResume));
  console.log("  turn counts       live " + String(live.turns.length) + ", resumed " + String(seenOnResume.length));
  console.log("  identical         " + String(JSON.stringify(live.turns) === JSON.stringify(seenOnResume)));
  console.log("  full value in checkpoint blob  " + String(stored));
  console.log("  error raised      none");
}

async function main() {
  await compare("appendFlat reducer", appendFlat);
  await compare("groupPerCall reducer", groupPerCall);
}

main().then(function () { process.exit(0); }).catch(function (e) {
  console.error("FAILED " + (e && e.stack ? e.stack : String(e)));
  process.exit(1);
});
text
appendFlat reducer
  live run saw      ["w1","w2","w3","w4","w5","w6"]
  resumed node saw  ["w1","w2","w3","w4","w5","w6"]
  turn counts       live 6, resumed 6
  identical         true
  full value in checkpoint blob  false
  error raised      none
groupPerCall reducer
  live run saw      [["w1"],["w2"],["w3"],["w4"],["w5"],["w6"]]
  resumed node saw  [["w1","w2","w3","w4","w5","w6"]]
  turn counts       live 6, resumed 1
  identical         false
  full value in checkpoint blob  false
  error raised      none

The grouping run produced six turns and the resuming node was handed one. Three things about that line are worth stating separately.

No error was raised. The run status is normal, the state has the right type, the writes are all present and in order, and the only thing that changed is where the boundaries are.

full value in checkpoint blob is false on both arms, which is the control that makes the result attributable. The channel really was absent from channel_values and really was reconstructed by replay. Had the value been stored whole, an identical result would have proved nothing.

Resuming a thread and discovering the state is not what you left is a recurring shape across agent frameworks; I measured a different version of it in resuming a suspended agent workflow after a restart, where the question was whether the state came back at all rather than whether it came back regrouped.

And the node is not an observer. It read s.turns the way any of your nodes would, so a branch on turn count, a summarise-every-N rule, or a loop bound now sees a different number after a restart than it saw before one. Whatever it writes next is appended to the regrouped value, so the fused history is then persisted.

the corruption is in the tail, and it comes and goes

Snapshot cadence decides how far back the replay has to walk. snapshotFrequency defaults to 1000 per-channel updates, confirmed in the shipped channel code as options?.snapshotFrequency ?? 1e3, so on any ordinary thread the replay covers everything. Setting it low makes the damage smaller rather than absent.

js
import { z } from "zod";
import { StateSchema, StateGraph, DeltaValue, MemorySaver, START, END } from "@langchain/langgraph";

function groupPerCall(current, writes) { return [...current, writes]; }

async function run(steps, freq) {
  const State = new StateSchema({
    turns: new DeltaValue(z.array(z.any()).default(function () { return []; }), {
      inputSchema: z.string(),
      reducer: groupPerCall,
      snapshotFrequency: freq,
    }),
    n: z.number().default(0),
  });
  const saver = new MemorySaver();
  const cfg = { configurable: { thread_id: "demo" } };

  const live = await new StateGraph(State)
    .addNode("step", function (s) { return { turns: "w" + String(s.n + 1), n: s.n + 1 }; })
    .addEdge(START, "step")
    .addConditionalEdges("step", function (s) { return s.n === steps ? END : "step"; }, ["step", END])
    .compile({ checkpointer: saver })
    .invoke({}, cfg);

  const snap = await new StateGraph(State)
    .addNode("noop", function (s) { return { n: s.n }; })
    .addEdge(START, "noop")
    .compile({ checkpointer: saver })
    .getState(cfg);

  const tuple = await saver.getTuple(cfg);
  const atTip = Object.prototype.hasOwnProperty.call(tuple.checkpoint.channel_values, "turns");
  const same = JSON.stringify(live.turns) === JSON.stringify(snap.values.turns);

  console.log("steps " + String(steps) + "  snapshotFrequency " + String(freq)
    + "  snapshot at tip " + String(atTip) + "  identical " + String(same));
  if (!same) {
    console.log("    live    " + JSON.stringify(live.turns));
    console.log("    resumed " + JSON.stringify(snap.values.turns));
  }
}

async function main() {
  for (const steps of [6, 7, 8, 9]) { await run(steps, 3); }
}

main().then(function () { process.exit(0); }).catch(function (e) {
  console.error("FAILED " + (e && e.stack ? e.stack : String(e)));
  process.exit(1);
});
text
steps 6  snapshotFrequency 3  snapshot at tip true  identical true
steps 7  snapshotFrequency 3  snapshot at tip false  identical true
steps 8  snapshotFrequency 3  snapshot at tip false  identical false
    live    [["w1"],["w2"],["w3"],["w4"],["w5"],["w6"],["w7"],["w8"]]
    resumed [["w1"],["w2"],["w3"],["w4"],["w5"],["w6"],["w7","w8"]]
steps 9  snapshotFrequency 3  snapshot at tip true  identical true

With a snapshot every three updates, only the eight-step run disagrees, and only in its last entry. The first six turns are perfectly preserved and the final two are fused. Six and nine land on a snapshot, so they reconstruct exactly. Seven has a single write since the last snapshot, and a batch of one replays as a batch of one.

That is the part to take seriously. A thread does not settle into a broken state. It is correct, then wrong, then correct again as it advances, and when it is wrong it is wrong only in the most recent entries, which is the region that looks most plausible and gets checked least.

What the documentation says, and where

The requirement is documented twice in the TypeScript declarations, on DeltaReducer and on the reducer option, both with the equation. The SDK also records in graph/messages_reducer.js that its own messagesDeltaReducer was written to satisfy it, with the same equation quoted. I counted the phrase batching-invariant separately per build, since the package ships both an ESM and a CommonJS copy: two occurrences in each, and all four are comments. There is no runtime check anywhere.

The constructor does validate something. It rejects a snapshotFrequency that is not a positive integer. So the tuning knob is checked at construction and the correctness contract is not.

On the documentation site, the picture is different. The LangGraph JS graph API guide, which is where state channels are explained, runs to 48,862 visible characters with navigation, header, sidebar and footer stripped, and contains DeltaChannel zero times. The same count is zero on the longer use-graph-api page and zero on the Python graph API page. UntrackedValue, a neighbouring channel type, is documented on those pages nine and six times respectively, so this is not a page that avoids channel types in general.

I also checked two pages that look relevant and are not usable evidence: the JS persistence page returns 200 but only 3,607 visible characters, and the durable execution path returns a 308 redirect. I am reporting those as uninformative rather than counting them as clean.

So the contract lives in the type declarations, and a reader who learns state channels from the guide will not meet it.

Why the signature invites the mistake

Compare the two reducer shapes the SDK offers.

ReducedValue hands you one write at a time: reducer(current, next). You cannot express per-step grouping in it even if you want to, because you never see a step.

DeltaValue hands you an array: reducer(current, writes). An array of writes that arrived together looks like a step. Treating it as one is the obvious reading, and it is the reading the replay is allowed to break.

The honest framing is that reducer(state, writes) looks like a per-step hook and is actually a fold that may be invoked with any batching the storage layer finds convenient. Once you hold it that way the rule follows: the reducer may read the writes, and it may not read how the writes were delivered.

This is not a LangGraph idea. Any system that reconstructs state by replaying a log at boundaries it chooses has the same constraint. Temporal documents a strict version of it for workflow code under determinism and constraints. I have not run Temporal and I am not claiming its mechanics match; I am pointing at the shape of the rule, which is older than either framework.

What to do

Run the Step 2 check on every DeltaValue reducer you own, in your test suite, with a handful of representative writes. It needs no graph, no checkpointer and no model, and it fails loudly on exactly the reducers that would otherwise fail quietly.

If a reducer fails it, you have three options, in order of preference. Rewrite it so the output does not encode batch boundaries, which usually means storing the step index on the write itself rather than inferring it from grouping. Move the field to ReducedValue and accept the full-blob writes, which is what the SDK suggests. Or keep the grouping and stop treating the reconstructed value as authoritative.

Do not reach for snapshotFrequency: 1 to make the symptom go away. It makes the replay depth zero, which hides the divergence and also discards the entire reason for using DeltaValue.

The four-step test, for any replayed reducer

This generalises past LangGraph, so it is worth stating without the framework attached.

  1. Find every reducer whose output is reconstructed by replaying a log rather than read back whole.
  2. Write down what the reducer reads. If it reads anything about the delivery of the writes, their count, their grouping, their arrival order relative to each other, it is a candidate.
  3. Fold the same writes at every split point and compare the results. Disagreement at any split is the defect.
  4. Verify on a real resume that the value was actually reconstructed rather than read from a stored blob, otherwise a passing result tells you nothing.

Step 4 is the one people skip, and it is the one that makes the other three mean something.

What I did not measure

I tested the in-process path: write with one compiled graph, read and resume with another, against MemorySaver. I did not test a database-backed checkpointer, and the replay walk goes through the saver, so a different saver is a different code path.

I did not test what happens when old writes are pruned. Since reconstruction walks ancestor writes, pruning the writes a surviving checkpoint still depends on is an obvious adjacent hazard, and it is a different failure from this one. I have not reproduced it and I am flagging it as untested rather than describing behaviour I have not seen.

I did not test parallel nodes writing the same channel in one superstep, which is the case where a batch genuinely has more than one write in it live.

And the feature is labelled beta in its own declarations, which say the API and on-disk representation may change. The behaviour here is a documented requirement being unenforced rather than a bug, so I would expect the requirement to outlive the version number even if the enforcement changes.

A fold is not a per-step hook. If the storage layer is allowed to re-batch your writes, anything your reducer infers from the batching is not state, it is an artefact of when the process happened to be restarted.

Ren Okabe

Written by

Ren Okabe

Ren builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.

Frequently asked questions

What does batching-invariant mean for a LangGraph DeltaValue reducer?

It means folding the same writes in any grouping gives the same result: reducer(reducer(state, xs), ys) must equal reducer(state, xs.concat(ys)). DeltaChannel reconstructs state by replaying checkpointed writes, and it is allowed to replay them in larger batches than your graph produced, so a reducer whose output depends on the grouping returns a different value after a resume.

Does LangGraph raise an error if my DeltaValue reducer is not batching-invariant?

No. Measured on @langchain/langgraph 1.4.19, the run completes normally and the reconstructed state is simply different. The phrase batching-invariant appears twice in each of the shipped ESM and CommonJS builds and every occurrence is a comment. The constructor does validate snapshotFrequency as a positive integer, so the tuning knob is checked and the correctness contract is not.

How do I check my reducer before deploying it?

Split a representative list of writes at every boundary, fold each split two ways, and compare. The ten-line check in this tutorial needs no graph, no checkpointer and no model. It flags a reducer that appends the batch itself or records the batch length, and correctly passes one that concatenates then trims a suffix.

Does the problem affect my nodes or only code that reads the state?

Your nodes. In the measured run the node that resumed the thread was handed one turn where the live run had produced six, so any branch on turn count, summarise-every-N rule or loop bound reads a different number after a restart. Whatever that node writes next is appended to the regrouped value, so the fused history is then persisted.

Does setting snapshotFrequency to 1 fix it?

It hides it. A snapshot every update means the replay depth is zero, so the stored blob is read back whole and the divergence disappears. That also discards the reason for using DeltaValue, which is to avoid re-serialising the accumulated value on every step. Fix the reducer instead, or move the field to ReducedValue.

Why does the corruption appear and disappear as the thread advances?

Replay only covers the writes since the last snapshot. With a snapshot every three updates, a six-step and a nine-step run land on a snapshot and reconstruct exactly, a seven-step run has a single write to replay and a batch of one replays as a batch of one, and only the eight-step run disagrees, in its final entry alone. The earlier history stays correct and the newest entries are the ones that fuse.

Is DeltaValue safe to use at all?

Yes, with a reducer that does not read the batching. The SDK ships messagesDeltaReducer, whose source comments record that it was written to satisfy the same equation. The constraint is on your reducer, not on the feature. The feature is labelled beta in its own declarations, which note the API and on-disk representation may change.

Is this specific to LangGraph?

No. Any system that reconstructs state by replaying a log at boundaries it chooses imposes the same constraint, which is why the four-step test in this tutorial is written without the framework attached. The article states explicitly that no other framework was run as part of this measurement.

Tutorials

Your Zod .refine() never reaches the model

Measured on ai 7.0.118 and zod 4.6.5: a .refine() on a tool input schema is stripped before the schema reaches the model, with no warning. z.string().startsWith('acct_') arrives as a pattern; the same rule written as a refinement arrives as nothing.

11 min read161