Refreshing event stream using langchain/react use stream replays old checkpoints

Support request: v2 protocol event replay on reconnect/first-subscribe degrades UI state (self-hosted LangGraph + @langchain/react useStream)

Description

We’re migrating our chat frontend to @langchain/react’s useStream (v2 streaming protocol) against a self-hosted LangGraph Platform deployment, and we’re seeing a family of problems that all trace back to the same protocol behaviour: the server replays the thread’s full buffered event history to every newly-opened subscription, and the client has no reliable way to distinguish that replay from live events.

The reason we are noticing this problem is we are trying to migration to @lanchain/react.

The user-visible impact on a page refresh of an existing thread:

  1. State rewind: on the first submit() after a refresh (the deferred root pump’s first open), the replay re-emits old runs’ values snapshots. Each one replaces the projected root state, so our UI visibly reverts — chat title back to its initial value, task sections and artefacts/sidebar collapsing — before walking forward through history to the current state.
  2. Loading/“thinking” indicator flicker and premature settle: replayed terminal lifecycle events from prior runs settle the loading state of the newly submitted run and trigger repeated subscription pause/resume cycles. On video we can watch the thinking indicator appear, vanish 4 seconds later (while the run is still executing server-side), and flicker with each replayed terminal. This looks like exactly what’s described in langgraphjs#2609.
  3. Delivery latency: the new run’s events queue behind the replay of all prior runs (we’ve measured 10–27 s of lag between server emission and client delivery mid-turn).

Versions

Backend (self-hosted):

Component Version
Docker base image langchain/langgraph-api (dated tag -20260710), Python 3.13
langgraph-api 0.10.3 (behaviour also verified unchanged in the 0.12.2 wheel and the 0.14.0.dev3 pre-release)
langgraph (core) 1.2.2 (also verified against 1.2.11, latest)
langgraph-checkpoint / -postgres 4.1.1 / 3.1.0
langchain-core 1.4.0
langgraph-cli / langgraph-runtime-inmem (local dev) 0.4.28 / 0.30.2 — all issues reproduce identically on the local dev server

Frontend:

Component Version
@langchain/react 1.0.28
@langchain/langgraph-sdk 1.9.27 (findings re-verified against the 1.9.29 tarball — the relevant modules are unchanged)
React 19

What we’ve noticed in our investigation

We instrumented the wire (every protocol event at ThreadStream’s intake, subscription pause/resume, projection decisions). Findings, with code references:

  1. The step-based staleness guard can never engage on Python backends. RootMessageProjection.applyValues treats a snapshot as a stale replay only when it carries a checkpoint step lower than the max applied (addOnly). Steps are paired from companion checkpoints-channel events — but those are never emitted for Python graphs: langgraph core (through 1.2.11) emits the legacy CheckpointPayload shape (config/metadata/values/next/parent_config, no top-level id), while langgraph_api.event_streaming.session’s checkpoints intake requires isinstance(data.get("id"), str) and silently drops everything else (verified through langgraph-api 0.14.0.dev3). We measured 0 checkpoints events out of 583 protocol events. Net effect: every replayed values snapshot is step-less and applied wholesale.
  2. Even when addOnly engages, it only protects the messages key — both branches of applyValues spread the incoming stale snapshot over the rest of the state ({ ...nextValues, [messagesKey]: messages }), so non-message state rewinds regardless.
  3. Replayed terminal lifecycle events affect the current run (the #2609 class): they settle loading state and each schedules #scheduleTerminalPause, which pauses all non-lifecycle subscriptions (we logged 14 pauses / 6 resumes in an 18-second window). SubscriptionHandle.pause() resolves pending iterators with done: true, so consumer loops exit and delivery becomes dependent on re-iteration after resume.
  4. Transport reconnection gaps: the since cursor is only sent on the first connection attempt (reconnects replay from seq 0 — absorbed by per-subscription event_id dedupe, but costly), and a clean stream close terminates openEventStream’s read loop permanently — reconnection exists only in the error path, which would affect deployed environments behind load balancers/proxies that idle-close connections.
  5. Message ordering: a turn’s messages arrive out of persisted order (the final message’s stream starts first; narration and tool/artefact messages are inserted before it at persist time). We’ve adapted our client to adopt the persisted order at run completion.

Our architecture

A single Python LangGraph graph on self-hosted langgraph-api; React SPA (React Router, SPA mode). The route loader fetches threads.getState() + latest run for hydration, then:

const stream = useStream<AgentState>({
  threadId,                    // from the route
  client,                      // langgraph-sdk Client (authenticated)
  assistantId,
  initialValues,               // loader's getState() values
  onThreadId, onCreated, onCompleted,
});

// stream.values  → app state (title, task sections, artefacts, sidebar viewing state)
// stream.messages → transcript
// plus custom channels via the thread handle (chain-of-thought, artefact
// content deltas, analytics …) emitted server-side with stream_mode="custom":
useChannelEffect(stream, ["custom:fd-state", "custom:fd-artefact-content", …], handler, { replay: false });

On onCompleted we re-fetch getState() and treat it as the authoritative snapshot. Non-message app state is driven directly from stream.values, which is why replayed snapshots are so visible for us.

Summary — what we’re really asking

Our core question is not about any single internal detail. It’s:

What is the intended/supported way to handle page refreshes and SSE reconnection when the protocol replays buffered events?

Concretely, for an app whose UI is driven by stream.values and stream.messages:

  1. On a page refresh of an existing thread (idle, or with a run in flight): what should the client do so that hydration + the subsequent replay converge cleanly — is there a combination of useStream options, hydration strategy, or subscribe parameters we should be using that we’ve missed? Are clients expected to treat replayed events as authoritative, or to ignore them in favour of getState() — and if the latter, how should a client distinguish replayed events from live ones?
  2. On reconnection (network blip, proxy/LB closing the stream, or a stream that ends cleanly): what’s the recommended pattern for rejoining the live stream without re-consuming — or being visibly affected by — the full replay, and without losing events emitted while disconnected?

Does any of this involve having to upgrade, or are there common patterns that I am missing to combat these problems we are facing?

Thanks for any help in advance, it is much appreciated.

Hi @reecemillsom - your investigation is correct on every point. I verified each claim against source (@langchain/langgraph-sdk 1.9.29 / @langchain/react 1.0.30, langgraph 1.2.11, and the langgraph-api 0.10.3 + 0.12.3 wheels). Upgrading won’t fix this today.

Root cause - a JS - Python parity gap. The client’s anti-replay machinery (checkpoint-step staleness guard, seeded-message seal, event_id dedup) depends on lightweight {id, parent_id, step, source} envelopes on the checkpoints channel. The JS runtime emits them natively (_emitValuesWithCheckpointMeta in langgraph-core/src/pregel/loop.ts). Python core still emits the legacy full-state map_debug_checkpoint dict, and langgraph_api.event_streaming.session drops anything without a top-level string id - in 0.10.3 and 0.12.3 alike. So step never arrives, addOnly never engages, and replayed values snapshots rewind the UI. Your “0 of 583” is exactly what the code predicts. Your other findings are real too: addOnly only protects messages; LifecycleLoadingTracker filters stale running but not stale terminals; clean stream close permanently ends the read loop (reconnect exists only in the error path).

Q1 (refresh): the intended model is getState() = authoritative, replay converges via the step guard - which Python backends starve. Until fixed, replicate the guard with data you control:

  1. Add a monotonic rev to your graph state (Annotated[int, max]), bumped by nodes that touch UI state.
  2. Don’t bind UI to stream.values directly - project through an apply-if-newer layer seeded from your loader’s getState().values.rev; skip snapshots with lower rev.
  3. Derive the thinking indicator from your own submit lifecycle (pending flag on submit(), cleared by first new token or newer rev), not stream.isLoading.

Q2 (reconnect): errored connections are handled (reconnect + full replay + event_id dedup; missed events recovered while the 10k ring buffer holds them - watch lg_api_protocol_v2_resume_gap_counter). For proxy idle-kills, tune streamIdleReconnect. Clean close is a genuine gap - the supported recovery is remounting the hook with the saved threadId (Join & rejoin).

Optional server stopgap: since the session forwards any checkpoints payload with a string id, you can patch langgraph.pregel._loop.map_debug_checkpoint to yield {"id": config["configurable"]["checkpoint_id"], "step": metadata["step"], "source": metadata["source"], "parent_id": ...} instead of the full-state dict. Caveats: private API, changes classic stream_mode=["checkpoints"]/"debug" output for other consumers, and it fixes staleness detection but not the non-message addOnly spread - keep the rev guard regardless.

Please file these (no existing issues cover them; your instrumentation makes a strong report): (1) Python envelope emission / langgraph-api synthesis shim → langchain-ai/langgraph; (2) addOnly non-message spread, (3) terminal-replay loading flicker, (4) clean-close reconnect → langchain-ai/langgraphjs.

imo three usable fixes today (they’re independent):

1. Client-side rev guard (recommended, safe). Replicates the SDK’s starved staleness guard with data you control. Graph side:

def _max_rev(a, b): return max(a or 0, b or 0)

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    rev: Annotated[int, _max_rev]

# in any node that mutates UI-relevant state:
return {"title": new_title, "rev": state.get("rev", 0) + 1}

Client side - stop binding UI to stream.values directly; project apply-if-newer, seeded from your route loader’s getState():

const lastRev = useRef(loaderState?.values?.rev ?? -1);
const [uiState, setUiState] = useState(loaderState?.values);

useEffect(() => {
  const v = stream.values;
  if (v?.rev != null && v.rev >= lastRev.current) {
    lastRev.current = v.rev;
    setUiState(v);
  }
}, [stream.values]);

Replayed snapshots carry older rev and get skipped - this kills the visible rewind regardless of what upstream does. Same idea for the thinking indicator: local pending flag set on submit(), cleared by the first new token or a newer rev, instead of stream.isLoading.

2. Server-side monkey-patch (riskier, fixes the mechanism itself). The session forwards any checkpoints payload with a top-level string id, so patch the mapper before your graph loads:

from langgraph.pregel import _loop

def _map_checkpoint_envelope(config, channels, stream_channels, metadata,
                             tasks, pending_writes, parent_config, output_keys):
    envelope = {
        "id": config["configurable"]["checkpoint_id"],
        "step": metadata["step"],
        "source": metadata["source"],
    }
    parent_id = (parent_config or {}).get("configurable", {}).get("checkpoint_id")
    if parent_id:
        envelope["parent_id"] = parent_id
    yield envelope

_loop.map_debug_checkpoint = _map_checkpoint_envelope

This makes the SDK’s addOnly guard and seal-lifting actually engage (verify with your wire instrumentation: you should see checkpoints events pairing each root values event). Caveats: private API (re-check on every langgraph upgrade), changes classic stream_mode=["checkpoints"]/"debug" output for other consumers (e.g. Studio debug views), and it does not fix the non-message addOnly spread - so keep workaround 1 anyway. Source-verified but I haven’t run it end-to-end.

3. For the clean-close disconnect: remount the hook with the saved threadId (bump a mountKey) when the stream ends - the documented join/rejoin pattern - and keep streamIdleReconnect on for proxy idle-kills.

Nothing helps the 10–27 s replay latency on long threads except shorter threads or a smaller server buffer (LSD_PROTOCOL_V2_BUFFER_SIZE, at the cost of resume window) - that queue-behind-replay behavior is by design until the upstream fixes land.

Is this related to Protocol v2: stale lifecycle replay settles first submit after thread hydration · Issue #2609 · langchain-ai/langgraphjs · GitHub?

Hi @pawel-twardziak ,

So sorry for the slow reply, I will take a look into the options that you have suggested and will reply hopefully sooner rather than later.

Really appreciate your responses :slight_smile:.

hi @reecemillsom

that’s totally fine :slight_smile:
And, look at the issue that @christian-bromann has mentioned above.