Pick the correct LangGraph 1.0 stream_mode ("messages" vs "updates" vs "values"),
wire it into SSE or WebSocket without proxy-buffering gotchas, and filter
astream_events(v2) server-side before forwarding to the browser. Use when
building a live-token chat UI, a per-node progress bar, a debug/time-travel
view, or diagnosing a LangGraph stream that hangs over a production proxy.
Trigger with "langgraph streaming", "stream_mode messages", "stream_mode updates",
"stream_mode values", "langgraph SSE", "langgraph astream_events", "SSE hangs
behind nginx", "cloud run streaming".
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Pick the correct LangGraph 1.0 stream_mode ("messages" vs "updates" vs "values"),
wire it into SSE or WebSocket without proxy-buffering gotchas, and filter
astream_events(v2) server-side before forwarding to the browser. Use when
building a live-token chat UI, a per-node progress bar, a debug/time-travel
view, or diagnosing a LangGraph stream that hangs over a production proxy.
Trigger with "langgraph streaming", "stream_mode messages", "stream_mode updates",
"stream_mode values", "langgraph SSE", "langgraph astream_events", "SSE hangs
behind nginx", "cloud run streaming".
Designed for Claude Code, also compatible with Codex
LangGraph Streaming (Python)
Overview
An engineer ships stream_mode="values" to a token-level chat UI because it
"seemed the most complete." Every single token causes the full graph state โ
message history, scratchpad, plan โ to be re-sent and re-rendered. At ~60
tokens/sec the browser overdraws, the React reconciler can't keep up, the tab
freezes, and users blame the model. The correct answer was stream_mode="messages",
which emits an AIMessageChunk delta per token (typically 5-50 bytes) โ one
token's worth of DOM work. This is pain-catalog entry P19 and it is the #1
LangGraph integration mistake in the 1.0 generation.
Then the same UI ships to Cloud Run and hangs forever. No error. No logs. The
server is emitting tokens; they just never reach the browser. Default proxy
buffering (Nginx, Cloud Run's HTTP/1.1 path, Cloudflare Free) holds the last
chunk waiting for more bytes. This is P46 โ SSE streams from LangGraph
drop the final end event over proxies that buffer โ and the fix is three
headers: X-Accel-Buffering: no, Cache-Control: no-cache, Connection: keep-alive.
And then the debug view starts crashing browser tabs on long runs. The engineer
forwarded astream_events(version="v2") raw to the client because "it has more
detail" โ but v2 emits thousands of events per invocation (per-token, per-node,
per-runnable lifecycle), and a 60-second agent run easily hits 3,000 events.
Browsers freeze on the JSON deserialize queue. This is P47 โ filter
server-side, forward only on_chat_model_stream tokens (and optionally
on_tool_start / on_tool_end).
This skill ships the decision matrix, a production-grade FastAPI SSE endpoint
with the anti-buffering headers and a 15-second heartbeat, a server-side v2
event filter that drops ~90% of noise, and a WebSocket variant with
reconnect-by-thread_id that resumes from the LangGraph checkpointer. Pin:
langgraph 1.0.x, langchain-core 1.0.x. Pain-catalog anchors: P19, P46,
P47, P48, P67, plus P16 for the thread_id rule and P22 for checkpointer
persistence.
A checkpointer: langgraph.checkpoint.memory.MemorySaver for dev, or
for prod
langgraph.checkpoint.postgres.PostgresSaver
Access to deploy behind your actual proxy (Nginx / Cloud Run / Cloudflare) โ
localhost does not reproduce the buffering class of bugs
Instructions
Step 1 โ Pick the right stream_mode for your UI
The three modes emit fundamentally different payloads. Match the mode to the
UI shape before writing any server code.
UI type
stream_mode
Payload each tick
Emit rate
Overdraw risk
Typical bandwidth per 5s run
Live-token chat
"messages"
(AIMessageChunk, metadata) delta
~30-80 tokens/sec
Low
~5-15 KB
Per-node progress bar / status line
"updates"
{node_name: state_diff}
1 per node (~2-20 per run)
Low
~1-5 KB
Debug / time-travel / state replay
"values"
Entire graph state dict
1 per node (~2-20 per run)
High (state size ร steps)
~20 KB to MBs
Hybrid (progress + tokens)
["updates", "messages"]
(mode, payload) interleaved
Sum of above
Depends on inner modes
Sum
Non-browser observability
astream_events(v2) + filter
Filtered dicts
Depends on filter
Low (server-controlled)
Controlled
Decision tree:
Do you need LLM tokens rendered live in the UI?
โโโ Yes โ stream_mode="messages"
โ (add "updates" to the list if you also want per-node progress)
โโโ No, I need per-step progress
โโโ Full state for debug/replay? โ stream_mode="values"
โโโ Just what changed (most UIs) โ stream_mode="updates"
import asyncio, json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from app.graph import build_graph
app = FastAPI()
graph = build_graph(checkpointer=MemorySaver())
defsse(event: str, data: dict) -> str:
returnf"event: {event}\ndata: {json.dumps(data, default=str)}\n\n"asyncdefstream_tokens(thread_id: str, user_input: str):
config = {"configurable": {"thread_id": thread_id}}
asyncfor chunk, metadata in graph.astream(
{"messages": [HumanMessage(user_input)]},
config=config,
stream_mode="messages",
):
# chunk.content may be list[dict] on Claude tool-use turns (P02)
text = chunk.text ifhasattr(chunk, "text") else (
chunk.content ifisinstance(chunk.content, str) elseNone
)
if text:
yield sse("token", {"text": text, "node": metadata.get("langgraph_node")})
yield sse("done", {"thread_id": thread_id})
Always use graph.astream(...) (async). Never call graph.stream(...) (sync)
from inside an async handler โ it blocks the event loop and one slow request
blocks every other connection (P48).
These three headers are non-negotiable in production. Without them, your
stream works on localhost and hangs on Cloud Run. See SSE Endpoint Template
for the full template with a 15-second heartbeat (required to survive Cloud
Run's 60s idle timeout and corporate-proxy timeouts) plus reverse-proxy
snippets for Nginx, Traefik, and Cloud Run.
If your UI needs richer events than "messages" provides โ tool start/end,
progress markers, retrieval events โ do not forward astream_events
raw. A single 60-second agent run can emit 3,000+ events. Filter on the
server and forward only what the browser uses.
Never use astream_log() in new code โ soft-deprecated in 1.0 (P67), scheduled
for removal in 2.0. Use astream_events(version="v2") instead. Full event
taxonomy and compression/backpressure patterns in
Astream Events Filtering.
Step 5 โ WebSocket variant with reconnect
Use WebSocket instead of SSE when the user may cancel, interrupt, or send
follow-up messages mid-stream. WebSocket also sidesteps Cloudflare Free's
default response buffering.
Because LangGraph checkpointers persist state per thread_id, a client that
reconnects to /ws/{same-thread-id} automatically sees the prior conversation
history on the next turn โ no special "resume" handshake required for
between-turn reconnects. For mid-stream reconnects and cancellation handling,
see WebSocket & Reconnect.
Step 6 โ Run the proxy-readiness checklist before shipping
A stream that works on uvicorn --reload main:app on your laptop will hang
behind Cloud Run. Before you ship, walk this checklist:
Deployed endpoint returns X-Accel-Buffering: no and Cache-Control: no-cache
curl -N https://your.app/stream?... shows tokens arriving incrementally โ NOT all at once at the end
Cloud Run deployed with --use-http2 (HTTP/2 end-to-end flushes chunks reliably)
If behind Cloudflare: Free plan may buffer โ test on Pro or use a paid page rule to disable response buffering (or switch to WebSocket)
15-second heartbeat configured (: heartbeat\n\n SSE comment every 15s) so idle streams don't get killed by the 60s timeout
Long-idle load test: run a tool-using agent that waits 45s on an API call; stream should stay alive
Test behind your actual proxy, not just localhost.
Output
stream_mode chosen deliberately from the decision matrix ("messages" for tokens, "updates" for progress, "values" for debug)
Minimal FastAPI SSE endpoint using graph.astream(..., stream_mode="messages") in an async handler
Required anti-buffering headers (X-Accel-Buffering, Cache-Control, Connection) on the StreamingResponse
Server-side astream_events(version="v2") filter that forwards only on_chat_model_stream + on_tool_start + on_tool_end
Optional WebSocket variant with thread_id required at the route and checkpointer-backed resume
Proxy-readiness checklist walked end-to-end before shipping past localhost
Error Handling
Symptom
Cause
Fix
Browser tab freezes on token stream
Shipped stream_mode="values" to a token UI; full state on every tick (P19)
Switch to stream_mode="messages" โ emits per-token deltas only
Per-node progress bar never advances
Shipped stream_mode="messages" to a per-node UI; no node-boundary events (P19)
Switch to stream_mode="updates"
Stream works on localhost, hangs on Cloud Run
Proxy buffering holds last chunk (P46)
Add X-Accel-Buffering: no, Cache-Control: no-cache headers; deploy Cloud Run with --use-http2
Stream closes after ~60s with no data
Idle-connection timeout on proxy
Send : heartbeat\n\n SSE comment every 15s
Browser tab freezes on long astream_events run
Forwarded unfiltered v2 events; 3,000+ events per run (P47)
Filter server-side: forward only on_chat_model_stream + optional tool events
DeprecationWarning: astream_log is deprecated
Using soft-deprecated API (P67)
Migrate to astream_events(version="v2")
Agent has amnesia on every WebSocket message
Missing thread_id in config (P16)
Require thread_id at route; assert in middleware
AttributeError: 'list' object has no attribute 'lower' on chunk.content
Claude streams content blocks, not plain strings on tool-use turns (P02)
Use chunk.text (1.0+) or check isinstance(chunk.content, str) before calling string methods
One slow request blocks all other WebSocket clients
Sync graph.stream() or graph.invoke() inside async handler (P48)
Always use graph.astream() / graph.ainvoke() in async contexts
Cloudflare Free plan buffers SSE
Free-tier response buffering
Upgrade plan with page rule to disable buffering, or switch endpoint to WebSocket
Examples
Live-token chat UI (the default case)
stream_mode="messages" plus SSE plus the three anti-buffering headers. One
token per SSE frame (~5-50 bytes each), 30-80 frames/sec during active model
generation, heartbeat every 15s during tool waits. See
SSE Endpoint Template for the complete
FastAPI example including heartbeat, reverse-proxy config, and the client
EventSource code.
Per-node progress bar for a multi-step agent
stream_mode="updates" yields one event per node (typically 2-20 per
invocation). Render as discrete status ticks: "Planning..." โ "Searching..." โ
"Summarizing..." โ "Done." Payload is tiny (~100 bytes per tick). Combine with
"messages" (stream_mode=["updates", "messages"]) to show both progress
ticks and streaming tokens in the active node's pane. Full payload samples in
Stream Mode Comparison.
Debug / time-travel view with "values"
stream_mode="values" yields the entire graph state after each node. Useful
for state replay, test recording, observability pipelines โ not for
browser UIs where state size ร steps ร re-render quickly freezes the tab.
Pipe to a server-side log (or LangSmith), not to the browser. Example and
caveats in Stream Mode Comparison.
WebSocket with reconnect-by-thread_id
When users can cancel mid-stream or send follow-up messages before the
previous turn finishes. The thread_id is required at the route; the
checkpointer persists history; reconnecting with the same thread_id
automatically sees prior turns. Cancellation is implemented via
asyncio.Task.cancel() on the active astream iteration. Worked example
with half-open connection detection in
WebSocket & Reconnect.