Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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
.claude/skills/jeremylongshore-langchain-langgraph-streaming/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 44% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 136% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 130% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 212% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 145% | 0% |
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.
langgraph >= 1.0, < 2.0, langchain-core >= 1.0, < 2.0fastapi >= 0.110, uvicorn[standard] (for SSE/WebSocket hosting)langgraph.checkpoint.memory.MemorySaver for dev, orlanggraph.checkpoint.postgres.PostgresSaver for prod
localhost does not reproduce the buffering class of bugs
stream_mode for your UIThe 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"Full payload samples and combined-mode examples are in Stream Mode Comparison.
pythonimport 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()) def sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, default=str)}\n\n" async def stream_tokens(thread_id: str, user_input: str): config = {"configurable": {"thread_id": thread_id}} async for 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 if hasattr(chunk, "text") else ( chunk.content if isinstance(chunk.content, str) else None ) 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).
python@app.get("/stream") async def stream(thread_id: str, q: str): return StreamingResponse( stream_tokens(thread_id, q), media_type="text/event-stream", headers={ "X-Accel-Buffering": "no", # Nginx / Cloud Run / Cloudflare "Cache-Control": "no-cache", # Block intermediate caches "Connection": "keep-alive", # Hold the TCP connection }, )
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.
astream_events(version="v2") server-sideIf 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.
pythonFORWARD = {"on_chat_model_stream", "on_tool_start", "on_tool_end"} async def filtered(graph, inputs, config): async for event in graph.astream_events(inputs, config=config, version="v2"): kind = event["event"] if kind == "on_chat_model_stream": chunk = event["data"]["chunk"] text = chunk.text if hasattr(chunk, "text") else None if text: yield {"type": "token", "text": text, "node": event["metadata"].get("langgraph_node")} elif kind == "on_tool_start": yield {"type": "tool_start", "tool": event["name"]} elif kind == "on_tool_end": yield {"type": "tool_end", "tool": event["name"]} # Drop: on_chain_*, on_parser_*, on_prompt_*, on_retriever_* (P47)
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.
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.
pythonfrom fastapi import WebSocket, WebSocketDisconnect @app.websocket("/ws/{thread_id}") async def ws(websocket: WebSocket, thread_id: str): await websocket.accept() config = {"configurable": {"thread_id": thread_id}} # P16 — always try: while True: msg = json.loads(await websocket.receive_text()) if msg["type"] == "user_message": async for chunk, metadata in graph.astream( {"messages": [HumanMessage(msg["text"])]}, config=config, stream_mode="messages", ): text = chunk.text if hasattr(chunk, "text") else None if text: await websocket.send_json({"type": "token", "text": text}) await websocket.send_json({"type": "done"}) except WebSocketDisconnect: pass # Checkpointer persists state; reconnect with same thread_id resumes
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.
A stream that works on uvicorn --reload main:app on your laptop will hang behind Cloud Run. Before you ship, walk this checklist:
Content-Type: text/event-stream (or 101 Switching Protocols for WebSocket)X-Accel-Buffering: no and Cache-Control: no-cachecurl -N https://your.app/stream?... shows tokens arriving incrementally — NOT all at once at the end--use-http2 (HTTP/2 end-to-end flushes chunks reliably): heartbeat\n\n SSE comment every 15s) so idle streams don't get killed by the 60s timeoutTest behind your actual proxy, not just localhost.
stream_mode chosen deliberately from the decision matrix ("messages" for tokens, "updates" for progress, "values" for debug)graph.astream(..., stream_mode="messages") in an async handlerX-Accel-Buffering, Cache-Control, Connection) on the StreamingResponseastream_events(version="v2") filter that forwards only on_chat_model_stream + on_tool_start + on_tool_endthread_id required at the route and checkpointer-backed resume| 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 |
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.
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.
"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.
thread_idWhen 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.
astream_events v2StreamingResponseproxy_buffering directivedocs/pain-catalog.md (entries P16, P19, P22, P46, P47, P48, P67)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 32,109 | 25,798 | -20% | 1 | 1 | 0% | 5,622 | 8,114 | +44% | 0 | 0 | — |
case-02 | pass→pass | 17,042 | 15,514 | -9% | 1 | 1 | 0% | 1,907 | 5,947 | +212% | 0 | 0 | — |
case-03 | pass→pass | 17,410 | 10,470 | -40% | 1 | 1 | 0% | 2,103 | 5,154 | +145% | 0 | 0 | — |
case-04 | pass→pass | 7,922 | 4,098 | -48% | 1 | 1 | 0% | 1,365 | 4,894 | +259% | 0 | 0 | — |
case-05 | pass→pass | 16,980 | 18,544 | +9% | 1 | 1 | 0% | 2,252 | 6,337 | +181% | 0 | 0 | — |
case-06 | pass→pass | 26,388 | 29,985 | +14% | 1 | 1 | 0% | 3,798 | 7,831 | +106% | 0 | 0 | — |
case-15 | fail→pass | 21,442 | 14,703 | -31% | 1 | 1 | 0% | 3,017 | 7,128 | +136% | 0 | 0 | — |
case-07 | fail→pass | 42,282 | 21,264 | -50% | 1 | 1 | 0% | 3,299 | 7,598 | +130% | 0 | 0 | — |
case-08 | pass→pass | 9,947 | 10,046 | +1% | 1 | 1 | 0% | 1,762 | 5,144 | +192% | 0 | 0 | — |
case-09 | pass→pass | 25,891 | 32,396 | +25% | 1 | 1 | 0% | 2,857 | 6,908 | +142% | 0 | 0 | — |
case-10 | pass→pass | 19,138 | 7,570 | -60% | 1 | 1 | 0% | 2,598 | 5,587 | +115% | 0 | 0 | — |
case-20 | pass→pass | 21,373 | 25,735 | +20% | 1 | 1 | 0% | 3,269 | 8,275 | +153% | 0 | 0 | — |
case-11 | pass→pass | 19,021 | 16,507 | -13% | 1 | 1 | 0% | 2,637 | 6,447 | +144% | 0 | 0 | — |
case-12 | pass→pass | 9,911 | 5,678 | -43% | 1 | 1 | 0% | 843 | 5,338 | +533% | 0 | 0 | — |
case-13 | pass→pass | 10,346 | 15,465 | +49% | 1 | 1 | 0% | 1,934 | 6,202 | +221% | 0 | 0 | — |
case-14 | pass→pass | 19,820 | 10,954 | -45% | 1 | 1 | 0% | 3,458 | 5,065 | +46% | 0 | 0 | — |
case-16 | pass→pass | 18,140 | 9,572 | -47% | 1 | 1 | 0% | 1,801 | 5,904 | +228% | 0 | 0 | — |
case-17 | pass→pass | 9,944 | 10,057 | +1% | 1 | 1 | 0% | 1,415 | 5,264 | +272% | 0 | 0 | — |
case-18 | pass→pass | 8,534 | 2,512 | -71% | 1 | 1 | 0% | 428 | 4,619 | +979% | 0 | 0 | — |
case-19 | pass→pass | 8,800 | 15,578 | +77% | 1 | 1 | 0% | 1,652 | 6,227 | +277% | 0 | 0 | — |
case-21 | pass→pass | 16,180 | 15,485 | -4% | 1 | 1 | 0% | 2,177 | 6,557 | +201% | 0 | 0 | — |
case-22 | pass→pass | 18,476 | 10,138 | -45% | 1 | 1 | 0% | 2,618 | 6,040 | +131% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +14 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.