DEV Community

Cover image for Two LangGraph.js Nodes Wrote the Same Key at Once. Reducers Decide Who Wins
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Two LangGraph.js Nodes Wrote the Same Key at Once. Reducers Decide Who Wins


Your research agent is slow. It searches the web, then the docs, then the ticket tracker, one after another, and each step waits on the last. So you do the obvious thing in LangGraph.js: fan out from START to three nodes that run side by side, each returning { findings: [...] }.

The first run doesn't get slower or faster. It throws an InvalidUpdateError. Nothing in the three nodes is wrong. The graph refused to guess which of three writes to the same key should survive, and it is right to refuse.

Two reducers fix it, and the same idea scales to a map-reduce with Send. The code makes no LLM calls, so it runs without an API key. I wrote it against the current docs and haven't run it myself, so pin your version (any release that exports StateSchema and ReducedValue) and check the error text on yours.

Reproduce it: three nodes, one key, one superstep

LangGraph executes in supersteps. The Graph API docs describe a superstep as "a single iteration over the graph nodes": nodes in the same superstep run in parallel, and when a node has several outgoing edges, its targets all run in the next superstep. Fan-out from START puts all three search nodes in one superstep.

Install LangGraph, its @langchain/core peer, and Zod for the field schemas, then run the files with tsx:

npm i @langchain/langgraph @langchain/core zod
npm i -D tsx typescript
Enter fullscreen mode Exit fullscreen mode

The broken version has three nodes writing one plain key:

// broken.ts
import {
  StateGraph, StateSchema, START, END,
} from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  question: z.string(),
  findings: z.array(z.string()).default(() => []),
});
type S = typeof State.State;

const web = async (s: S) => ({
  findings: [`web: ${s.question}`],
});
const docs = async (s: S) => ({
  findings: [`docs: ${s.question}`],
});
const tickets = async (s: S) => ({
  findings: [`tickets: ${s.question}`],
});

const graph = new StateGraph(State)
  .addNode("searchWeb", web)
  .addNode("searchDocs", docs)
  .addNode("searchTickets", tickets)
  .addEdge(START, "searchWeb")
  .addEdge(START, "searchDocs")
  .addEdge(START, "searchTickets")
  .addEdge(["searchWeb", "searchDocs", "searchTickets"], END)
  .compile();

graph
  .invoke({ question: "why is p99 up?" })
  .then(console.log)
  .catch((e) => console.error(e.name, e.message));
Enter fullscreen mode Exit fullscreen mode

A key with no reducer gets the default behaviour, which the docs describe as ignoring the old value and replacing it with the new one. That is fine when one node writes per step. Three writes in one step leave nothing to decide the winner, so the channel rejects the update.

The message is assembled from strings in the LangGraph.js source. The channel itself throws "LastValue can only receive one value per step.", and the write-apply step wraps it on one line with the channel name and the values it received. With the values elided, the message starts like this: Invalid update for channel "findings" with values [...]: LastValue can only receive one value per step. Because the error carries a code, the base error class also appends a "Troubleshooting URL" line pointing at the docs, so the message you see is longer than that.

The error code is INVALID_CONCURRENT_GRAPH_UPDATE, and the troubleshooting page for it gives the one-line rule: every key that parallel nodes write to needs a reducer. Before you debug this in a real graph, know that a superstep is transactional. The how-to guide says that if any parallel branch raises an exception, none of that superstep's updates are applied to the state.

Three parallel arrows from separate nodes converging on a single slot, colliding at the point of entry
Three writes to one plain key in one superstep. The graph won't pick a winner for you.

Fix one: an append reducer

A reducer is a function of two arguments. Per the docs, "the left argument always comes from accumulated state" and "the right argument always comes from the latest node update." In StateSchema, you attach one with ReducedValue:

import { ReducedValue } from "@langchain/langgraph";

const State = new StateSchema({
  question: z.string(),
  findings: new ReducedValue(
    z.array(z.string()).default(() => []),
    { reducer: (cur, next) => cur.concat(next) },
  ),
});
Enter fullscreen mode Exit fullscreen mode

Swap that in and the same three nodes run cleanly. LangGraph folds each update into the accumulated value one at a time, so findings ends up with three entries.

The part people trip over is order: which entry comes first. The how-to guide says it directly: "updates from a parallel superstep may not be ordered consistently." If findings[0] is the web result today, nothing promises it will be tomorrow. Code downstream that reads findings[0] as "the web result" has a bug that shows up on some runs and not others. The docs' advice: write a value that encodes the order you want next to the output, and sort on it.

If you are on the older API, the same reducer under Annotation.Root looks like this. The docs now list Annotation.Root as legacy and StateSchema as the recommended path, but StateGraph still accepts both:

findings: Annotation<string[]>({
  reducer: (cur, next) => cur.concat(next),
  default: () => [],
}),
Enter fullscreen mode Exit fullscreen mode

Fix two: merge by id, keep the highest score

Appending is fine for log-like data. Research results usually aren't. Two sources find the same incident; you want one entry per id, and you want the better-scored one. That calls for a reducer that merges:

const Finding = z.object({
  id: z.string(),
  source: z.string(),
  text: z.string(),
  score: z.number(),
});
type Finding = z.infer<typeof Finding>;

// Total order: higher score, then source, then text.
const rank = (a: Finding, b: Finding) =>
  b.score - a.score ||
  a.source.localeCompare(b.source) ||
  a.text.localeCompare(b.text);

function keepBest(cur: Finding[], next: Finding[]) {
  const byId = new Map(cur.map((f) => [f.id, f]));
  for (const f of next) {
    const old = byId.get(f.id);
    if (!old || rank(f, old) < 0) byId.set(f.id, f);
  }
  return [...byId.values()].sort(
    (a, b) => rank(a, b) || a.id.localeCompare(b.id),
  );
}

const State = new StateSchema({
  question: z.string(),
  findings: new ReducedValue(
    z.array(Finding).default(() => []),
    { reducer: keepBest },
  ),
});
Enter fullscreen mode Exit fullscreen mode

Two pieces in there exist only because of the ordering problem. The tie-break in rank matters: if two sources return the same id with the same score and you keep "whichever arrived first," your output depends on arrival order. A total order (score, then source, then text) makes the winner the same no matter which branch finishes first. The final sort matters for the same reason: a Map remembers insertion order, and insertion order is arrival order.

That is the test to run on any reducer for parallel branches. Feed it the same updates in a different order. If the result changes, the reducer is depending on something LangGraph doesn't promise.

Each node now returns { findings: [{ id, source, text, score }] } and the reducer does the rest. No node needs to know the others exist.

Scale it: map-reduce over N documents with Send

Three hard-coded branches work when you know the branches at build time. When the work is "one branch per document" and the documents arrive in state, you need Send. The Graph API docs describe it for exactly this case: you return Send objects from a conditional edge, each with a target node name and the state that node should receive.

The reducer here dedupes claims extracted from many documents. Two documents that state the same claim produce one entry carrying both document ids. The example is a single file, mapreduce.ts, shown in four parts; paste them into one file in order. First the imports and the two schemas:

// mapreduce.ts (1/4): imports and schemas
import {
  StateGraph, StateSchema, ReducedValue, Send, START, END,
} from "@langchain/langgraph";
import * as z from "zod";

const Doc = z.object({ id: z.string(), body: z.string() });
const Claim = z.object({
  key: z.string(),
  text: z.string(),
  docIds: z.array(z.string()),
});
type Claim = z.infer<typeof Claim>;
Enter fullscreen mode Exit fullscreen mode

Then the reducer and the state. doc stays a plain key: each branch reads it from its Send payload, and none of them return it.

// mapreduce.ts (2/4): reducer and state
function mergeClaims(cur: Claim[], next: Claim[]) {
  const byKey = new Map(cur.map((c) => [c.key, c]));
  for (const c of next) {
    const old = byKey.get(c.key);
    if (!old) { byKey.set(c.key, c); continue; }
    byKey.set(c.key, {
      key: c.key,
      text: old.text < c.text ? old.text : c.text,
      docIds: [...new Set([...old.docIds, ...c.docIds])]
        .sort(),
    });
  }
  return [...byKey.values()].sort(
    (a, b) => a.key.localeCompare(b.key),
  );
}

const State = new StateSchema({
  docs: z.array(Doc).default(() => []),
  doc: Doc.optional(),
  claims: new ReducedValue(
    z.array(Claim).default(() => []),
    { reducer: mergeClaims },
  ),
  report: z.string().default(""),
});
type S = typeof State.State;
Enter fullscreen mode Exit fullscreen mode

The nodes. extract splits one document into claims, write turns the merged claims into a report, and fanOut returns one Send per document:

// mapreduce.ts (3/4): nodes and fan-out
const normalize = (t: string) =>
  t.toLowerCase().replace(/[^a-z0-9 ]/g, "").trim();

const extract = async (s: S) => {
  const doc = s.doc;
  if (!doc) throw new Error("extract needs a doc");
  const claims = doc.body
    .split(".")
    .map((t) => t.trim())
    .filter(Boolean)
    .map((text) => ({
      key: normalize(text), text, docIds: [doc.id],
    }));
  return { claims };
};

const write = async (s: S) => ({
  report: s.claims
    .map((c) => `${c.text} [${c.docIds.join(", ")}]`)
    .join("\n"),
});

const fanOut = (s: S) =>
  s.docs.map((doc) => new Send("extract", { doc }));
Enter fullscreen mode Exit fullscreen mode

Last, the graph and a run over two documents that share a claim:

// mapreduce.ts (4/4): graph and run
const graph = new StateGraph(State)
  .addNode("extract", extract)
  .addNode("write", write)
  .addConditionalEdges(START, fanOut)
  .addEdge("extract", "write")
  .addEdge("write", END)
  .compile();

const docs = [
  { id: "a", body: "Cache hit rate fell. Deploy 412 went out." },
  { id: "b", body: "Deploy 412 went out. Disk is fine." },
];

graph
  .invoke({ docs }, { maxConcurrency: 4 })
  .then((out) => console.log(out.report));
Enter fullscreen mode Exit fullscreen mode

Each Send("extract", { doc }) starts its own extract run with its own input, and all of them land in the same superstep. That is why claims needs a reducer: N branches write it at once.

A funnel where many document sheets drop in at the top and a smaller set of distinct cards comes out of the bottom
Map with Send, reduce with a merge that doesn't care which branch finished first.

maxConcurrency caps how many branches run at once. The how-to guide notes it goes at the top level of the invoke config, not inside configurable. With real LLM calls inside extract, that cap is what keeps 200 documents from becoming 200 simultaneous requests against your rate limit.

Look at mergeClaims with the ordering test from earlier. The text pick is "smaller string wins," not "first one seen wins." The ids go through a Set and then a sort. The output list is sorted by key. Shuffle the order the branches finish in and report comes out byte-identical. That property is the whole job of a reducer that sits behind a fan-out.

Reducer rules for parallel branches

A short checklist, drawn from the docs and from the failure above:

  1. Every key written by two or more nodes in the same superstep needs a reducer. A plain key works only when exactly one writer touches it per step.
  2. Never read position as meaning. findings[0] is not "the web result." Put the source in the value and sort on it.
  3. Make the reducer order-independent. Shuffle the updates; if the result changes, add a tie-break or a final sort.
  4. Keep reducers pure. LangGraph calls them when it applies the step's writes, so the result should depend only on the two arguments. Keep I/O and randomness out of them.
  5. Know how to reset. The Graph API docs note that a merging reducer won't clear a field when a node returns an empty value; they describe wrapping the update in Overwrite when you need to replace it.

Before you add the next branch, run the shuffle test from rule 3 on every reducer you already have.


If this was useful

Parallel branches, state that has to survive many writers, and graphs that keep their shape as they grow are what AI That Plans is about. It is book 4 of AI in TypeScript and covers LangGraph.js state, memory, and multi-step and multi-agent graphs, all in TypeScript. If this post was the first time a reducer felt like a design decision and not boilerplate, the book picks up from there.

AI That Plans — AI in TypeScript

The whole series, from your first LLM call to agents in production, is at xgabriel.com/ai-in-typescript.

Top comments (0)