The complete, confirmed mechanics of streaming a Claude response into a real UI — the exact SSE event sequence (message_start, content_block_start/delta/stop, message_delta, message_stop) emitted by the currently-installed @anthropic-ai/sdk, why streaming is required above roughly 16K output tokens to avoid HTTP timeouts, and how a paused tool-use turn differs from a finished one.
streamingsseserver-sent-eventschat-uiaianthropic
Knowledge Check
16 questions · pass at 70%
0/16
easymcq
1. Which SSE event type carries the incremental text fragments a client should render?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
STREAMING CALL:
const stream = client.messages.stream({ model, max_tokens, messages });
for await (const event of stream) { ... }
const finalMessage = await stream.finalMessage(); // full Message, incl. usage
EVENT SEQUENCE (fixed order, every normal turn):
message_start → metadata only, NO text yet
content_block_start → announces a block's type (text | tool_use | thinking)
content_block_delta → incremental fragment:
text_delta → delta.text (APPEND, never assign)
input_json_delta → delta.partial_json (accumulate, parse once at stop)
thinking_delta → delta.thinking
content_block_stop → one block done (may repeat for multiple blocks)
message_delta → delta.stop_reason + running usage
message_stop → entire message complete
RULE #1: renderedText += event.delta.text (NOT =)
RULE #2: JSON.parse(accumulated) only AFTER content_block_stop for that block
WHEN STREAMING IS REQUIRED, NOT OPTIONAL:
Non-streaming requests risk HTTP timeouts above ~16K expected output tokens.
This applies even to backend jobs with no live UI consumer.
TOOL-USE PAUSE MID-STREAM:
stop_reason: "tool_use" → model is WAITING on you, not finished.
Execute the tool, send a NEW request with a tool_result — stream for
THIS turn has ended; the interaction continues on the next call.
BROWSER WIRING:
Browser → your backend route (SSE/ReadableStream) → your backend → Claude stream
Browser NEVER talks to Anthropic directly (API key stays server-side).
A non-streaming client.messages.create() call waits for the entire response to finish generating before returning anything to your code — for a long answer, that can be many seconds of a blank screen. Streaming instead delivers the response as a sequence of incremental Server-Sent Events (SSE) as the model generates each piece, which is what makes the token-by-token "typing" effect you see in every production AI chat UI possible.
Calling .stream() opens a long-lived connection instead of waiting for one full response. Nothing has arrived yet — the UI should show a pending state, not a blank one.
Event
Fires
What it carries
message_start
Once, first
Message metadata — id, model, empty usage. No text yet.
content_block_start
When a new block begins
The block's type (text, tool_use, thinking)
content_block_delta
The client's job at the delta stage is nothing more than string concatenation: append delta.text to whatever's already rendered. Never replace the rendered text — deltas are fragments, not full snapshots.
Why streaming is required, not just nice-to-have, above a size threshold#
Non-streaming requests are held open by both the SDK's HTTP client and (depending on your deployment) an infrastructure-level proxy timeout. For any response likely to exceed roughly 16,000 output tokens, a non-streaming call risks hitting one of those timeouts before the model finishes — the request simply fails, even though the model would have eventually produced a complete answer. Streaming sidesteps this because the connection is actively delivering bytes the whole time, so it's never "idle" long enough to trip a timeout.
// A long-form generation task — must stream, not just "may want to"const stream = client.messages.stream({ model: "claude-opus-4-8", max_tokens: 64000, // well above the ~16K non-streaming safety threshold messages: [{ role: "user", content: "Write a detailed technical design doc for..." }],});
Wiring a stream to a real UI: Server-Sent Events over HTTP#
In a browser-facing app, the pattern is: your backend route opens a Claude stream, and re-emits it to the browser as its own SSE response (or you adapt it to a ReadableStream for fetch-based consumption). The browser never talks to Anthropic directly (see the SDK integration topic for why).
// app/api/chat/route.ts — Next.js Route Handler streaming back to the browserimport Anthropic from "@anthropic-ai/sdk";const client = new Anthropic();export async function POST(req: Request) { const { prompt } = await req.json(); const claudeStream = client.messages.stream({
On the client, a simple fetch with a readable-stream body reader is enough to render tokens as they arrive — no special browser API beyond what fetch already provides for reading a streamed response body incrementally.
Streaming and tool use compose — a streamed response can include ordinary text, then a tool_use block whose input fills in incrementally via input_json_delta fragments, then stop with stop_reason: "tool_use" instead of "end_turn".
A streamed turn that calls a tool: text deltas, then an input_json_delta block, then stop_reason: tool_use
step 1 / 4
// content_block_start — index 0, type: 'text'
// a few text_delta events render normally
Block 0 (text)
active
"Let me check the weather..."
→
Block 1 (tool_use)
idle
not started
Rendered UI
Let me check the weather for you.
Claude can stream ordinary explanatory text before deciding to call a tool — this is not an error state, just an earlier content block in the same message.
The critical detail: input_json_delta fragments are partial JSON strings. Concatenate them as you receive them, but only call JSON.parse() on the fully accumulated string once content_block_stop fires for that block — parsing a partial fragment mid-stream will throw, since {"locat is not valid JSON on its own.
If a beginner's code does renderedText = event.delta.text (assignment) instead of renderedText += event.delta.text (concatenation) inside the loop, what does the final rendered UI show?
Solution
"fox." — just the last delta, with everything before it silently overwritten. Each content_block_delta event carries only its own small text fragment, not the full accumulated text so far — the client is responsible for concatenating every fragment itself. Using = instead of += is one of the most common streaming bugs: it looks correct in a quick manual test (since the LAST delta happens to contain the tail of a real sentence, which can look plausible on its own), but it silently discards every earlier fragment, producing truncated or nonsensical output for any response longer than a single delta chunk.
Build a minimal hand-rolled SSE line parser — the actual mechanism a fetch-based streaming client (or the SDK internally) uses to turn a raw byte stream into individual events, without any external streaming library.
async function parseSSEStream(response: Response, onEvent: (eventType: string, data: unknown) => void) { const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer =
This is close to the real mechanism: SSE frames are text, delimited by blank lines, each with an event: line and a data: line — parsing them by hand makes it obvious why buffering an incomplete trailing frame (the buffer = frames.pop() line) matters, since a chunk boundary from the network can split one logical frame across two reader.read() calls.
The non-blocking, incremental nature of consuming a stream — for await (const event of stream) never blocks the rest of your server process while waiting for the next chunk — is the same asynchronous I/O model that underlies The Node.js Event Loop and, more generally, The JavaScript Event Loop: a stream is conceptually a sequence of deferred callbacks/microtasks resolving over time, not a single synchronous blocking read. On the wire, re-emitting Claude's stream to the browser as your own SSE response is exactly the request/response streaming model covered in Building an HTTP Server in Node.js — your Route Handler is itself acting as an HTTP server sending a chunked, long-lived response body.
1. Assigning instead of concatenating rendered text#
// ❌ overwrites everything each delta, final UI shows only the LAST fragmentlet rendered = "";for await (const event of stream) { if (event.type === "content_block_delta" && event.delta.type === "text_delta") { rendered = event.delta.text; // should be += }}
Deltas are fragments, not cumulative snapshots — always append, never replace.
2. Parsing input_json_delta fragments as JSON individually#
// ❌ throws — partial JSON fragments aren't valid JSON on their ownfor await (const event of stream) { if (event.delta?.type === "input_json_delta") { const parsed = JSON.parse(event.delta.partial_json); // "{\"locat" is not valid JSON }}
Accumulate every partial_json fragment into one string across the whole block, and only call JSON.parse() once, after content_block_stop fires for that block.
3. Treating a non-streaming request as always safe regardless of expected output length#
// ❌ risks an HTTP timeout for a long-form generation taskconst response = await client.messages.create({ model: "claude-opus-4-8", max_tokens: 64000, // large expected output, but NOT streaming messages: [{ role: "user", content: "Write an exhaustive technical spec for..." }],});
Above roughly 16,000 expected output tokens, use client.messages.stream() instead — a non-streaming call that large risks failing on an HTTP timeout partway through generation, wasting the tokens the model already produced.
Always concatenate delta.text, never assign — a stream's rendered output is the sum of every fragment received so far, not just the latest one.
Buffer input_json_delta fragments and parse only after content_block_stop — partial JSON is not valid JSON.
Use client.messages.stream() for any response that might exceed ~16K output tokens — this isn't an optimization, it's what prevents an otherwise-successful generation from failing on an HTTP timeout.
Call stream.finalMessage() (or the equivalent await pattern) at the end to get the fully-assembled Message object — including usage and stop_reason — instead of hand-tracking every field yourself.
Buffer a handful of tokens client-side before triggering a DOM re-render, rather than re-rendering on every single delta — many chat UIs batch deltas into ~50–100ms render ticks to avoid excessive reflow on very fast streams.
Prefer the SDK's stream.on("text", ...) event (where available) over manually filtering every raw content_block_delta — it hands you just the delta string directly.
Reuse one long-lived HTTP connection per turn rather than polling — SSE's whole design point is to avoid the overhead and latency of a request-per-update polling loop.
Per incremental chunk
delta.type: "text_delta" with a text fragment (or thinking_delta, input_json_delta)
content_block_stop
When a block finishes
—
message_delta
Near the end
delta.stop_reason and running usage
message_stop
Once, last
Stream is fully done
model: "claude-opus-4-8",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
for await (const event of claudeStream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
if (eventType === "content_block_delta" && data.delta?.type === "text_delta") {
rendered += data.delta.text; // concatenate — never assign
}
});
Never let the browser talk to Anthropic directly — stream through your own backend route, which itself streams to the client via SSE or a ReadableStream.