In plain English
When you call an LLM normally, you wait for the whole answer, then read it. Streaming flips that: the model sends its answer in many small pieces as it writes them, so you can show text the instant it appears. That's why a chat UI types out a reply word by word instead of freezing for ten seconds. If the idea of streaming itself is new to you, start with What is LLM streaming — this article is the next step: how to actually read the pieces.

Here's the catch the intro glosses over. A stream is not a series of bigger-and-bigger versions of the final text. It's a sequence of typed events, and most of them carry a tiny fragment called a delta — the new characters since the last event. The full message only exists once you glue the deltas back together in order. The provider hands you a flipbook, one frame at a time; the finished picture is something you assemble.
Think of a sports commentator on the radio. They don't read you the final match report — they narrate as it happens: "the match has started" (an opening event), then "...he passes... to the winger... who shoots..." (a run of small updates), then "and that's full time, final score two-one" (a closing event with the totals). You, the listener, hold the running picture in your head. Parsing a stream is exactly that job: catch each fragment, append it in the right place, and watch for the bookend events that tell you a section started, a section ended, and the whole thing is done.
Why it matters
If you only ever call the non-streaming endpoint, you never touch any of this — you get one finished object and read .content. So why learn the event-by-event mechanics at all? Because the moment your output is more than a sentence or two, streaming stops being optional, and once you stream, you own the reassembly.
- Perceived speed. A user staring at a blank screen for eight seconds feels slow; the same eight seconds feels fast if words start appearing in 300ms. Streaming doesn't make the model faster — it makes the wait visible and tolerable.
- Avoiding timeouts on long outputs. Big responses (long reports, large code files, high
max_tokens) can take longer than a single HTTP request is allowed to stay open. Streaming keeps bytes flowing the whole time, so the connection never looks idle and times out. For largemax_tokensthis isn't a nicety — it's the only reliable way to get the response. - Reacting before the model finishes. Want to stop generation the instant the model emits a stop phrase, update a progress bar, or start running a tool the moment its arguments are complete? You can only do that if you're reading the stream as it arrives.
- Capturing the metadata correctly. Token usage and the
stop_reason(why the model stopped) don't arrive in the deltas — they live on specific framing events. If you reassemble text but ignore those, you lose your cost accounting and your ability to tell "finished normally" from "got cut off at the token limit."
The failure mode for a builder who skips the details is subtle: the demo works (you concatenate text deltas, it looks fine), then production breaks. A tool call's arguments arrive as fragments and you JSON.parse a half-written string. A response hits max_tokens and you treat the truncated text as complete. Usage reads as zero because you looked at the wrong event. Understanding the event lifecycle is what turns "it worked on my screen" into "it works."
How it works
Every stream follows the same shape: a message opens, one or more content blocks are streamed (each block opens, fills with deltas, and closes), then the message closes with final metadata. Content blocks are the key idea — a single reply can contain several (a thinking block, then a text block, then a tool-call block), and each one is streamed independently with its own index.
The event types, and what each one carries
These are the Anthropic Messages API event names; other providers use different labels but the same six roles. Learn the roles and you can read any provider's stream.
| Event | Fires | What you do with it |
|---|---|---|
message_start | Once, first | Grab the message shell. Its usage already has input_tokens; output_tokens is still ~0 here. |
content_block_start | Each block opens | Note the block's index and type (text, tool_use, thinking). Initialize a buffer for it. |
content_block_delta | Many times | The workhorse. Append delta.text (text), or delta.partial_json (tool args), to the buffer for that index. |
content_block_stop | Each block closes | That block's content is now complete. Safe to JSON.parse accumulated tool args here. |
message_delta | Near the end | Carries the top-level stop_reason and the final cumulative usage.output_tokens. |
message_stop | Once, last | The stream is over. Nothing more will arrive. |
Reassembly: append by index, in arrival order
The whole job is: keep one buffer per block index, and on every content_block_delta append the fragment to the matching buffer. Text deltas are plain strings — concatenate them. Tool-call deltas are partial JSON strings — concatenate the fragments and only parse the result once the block stops. Never parse a tool call's arguments mid-stream; the JSON is incomplete until content_block_stop.
content_block_start {type: tool_use, name: get_weather, index: 0}
content_block_delta {partial_json: "{\"ci"}
content_block_delta {partial_json: "ty\": \"Par"}
content_block_delta {partial_json: "is\"}"}
content_block_stop -> now join: {"city": "Paris"} -> JSON.parseNotice the JSON arrives split across fragments at arbitrary points — {"ci ... ty": "Par ... is"}. Any single fragment is unparseable on its own. This is the single most common streaming bug: calling the JSON parser on a delta instead of on the joined, completed string.
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: "Weather in Paris?" }],
tools: [getWeatherTool],
});
const textByIndex: Record<number, string> = {};
const jsonByIndex: Record<number, string> = {};
let stopReason: string | null = null;
for await (const event of stream) {
switch (event.type) {
case "content_block_start":
textByIndex[event.index] = "";
jsonByIndex[event.index] = "";
break;
case "content_block_delta":
if (event.delta.type === "text_delta")
textByIndex[event.index] += event.delta.text; // append text
else if (event.delta.type === "input_json_delta")
jsonByIndex[event.index] += event.delta.partial_json; // append tool args
break;
case "content_block_stop":
// tool args are complete now — safe to parse
if (jsonByIndex[event.index])
console.log(JSON.parse(jsonByIndex[event.index]));
break;
case "message_delta":
stopReason = event.delta.stop_reason; // why it stopped
break;
}
}Where usage and stop_reason live
This trips up almost everyone the first time. In a non-streamed call you get one object with stop_reason and a complete usage block sitting right there at the top level. In a stream, those same two values are split across the framing events — and usage is split across two events. Read the wrong one and you'll log zero tokens or a missing stop reason.
- `response.stop_reason` — present immediately
- `response.usage.input_tokens` — present
- `response.usage.output_tokens` — present (final)
- Read `.content[0].text` for the answer
- Everything in one object
- `stop_reason` arrives on `message_delta`
- `input_tokens` arrives on `message_start`
- `output_tokens` is final on `message_delta`
- Reassemble text from `content_block_delta`s
- Spread across framing events
- Input tokens are known up front — the prompt is already counted — so they ride on
message_start. At that pointoutput_tokensis still near zero because nothing has been generated yet. - Output tokens keep growing as the model writes, so the final count lands on
message_deltaat the end. That samemessage_deltacarries the top-levelstop_reason. stop_reasontells you why generation ended:end_turn(finished naturally),max_tokens(hit your limit — output is truncated, treat it as incomplete),tool_use(the model wants to call a tool),stop_sequence(hit a stop string), orrefusal. Branch on this before trusting the reassembled text.
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 4096,
messages: [{ role: "user", content: "Write a short poem." }],
});
// (optionally iterate events here to render tokens live)
const message = await stream.finalMessage();
console.log(message.content[0].text); // full text, reassembled
console.log(message.stop_reason); // e.g. "end_turn"
console.log(message.usage.output_tokens); // final, summedCommon pitfalls
- Parsing tool JSON mid-stream. Each
input_json_deltais a fragment. CallingJSON.parseon one (or on a half-built buffer) throws. Concatenate everypartial_jsonfor that block, then parse once atcontent_block_stop. - Treating a
max_tokensstop as a complete answer. Ifstop_reasonismax_tokens, the model was cut off. The text looks finished but isn't. Check the stop reason before using the result; raisemax_tokensand retry if needed. - Ignoring the block
index. A reply can interleave multiple content blocks. If you dump every text delta into one global string without keying byindex, you'll merge a thinking block, a text block, and tool arguments into nonsense. Buffer per index. - Reading
output_tokensfrommessage_start. It's ~0 there. The real output count is onmessage_deltaat the end (or summed for you by the final-message helper). - Hand-rolling the SSE parser. Splitting the raw byte stream on newlines, handling partial lines, decoding
data:JSON, and re-joining multi-line events is fiddly and easy to get wrong. The official SDK already does it — iterate the SDK stream and switch onevent.typeinstead. - Forgetting errors can arrive mid-stream. A connection can drop or the API can emit an error event after some text already streamed. Wrap the loop in error handling and decide what to do with the partial output you've collected so far.
Going deeper
Once the basic lifecycle clicks, a few more nuances separate a toy parser from a production one.
Streaming with tool use is a loop, not a single pass. When a stream ends with stop_reason: "tool_use", you've reassembled the tool name and its (now-complete) JSON arguments, but the turn isn't over. You execute the tool, append the result to the conversation, and stream again. The model's final answer comes in the next stream. So tool-call reassembly feeds an outer agentic loop — parse the arguments at content_block_stop, run the tool, send the result back, repeat until stop_reason is end_turn.
Thinking blocks stream too. When adaptive thinking is on, the model's reasoning arrives as its own content block before the answer block — with thinking_delta fragments instead of text_delta. Treat it like any other block (buffer by index), but render it differently in your UI (a collapsed "thinking…" panel) or not at all. The point is: don't assume every block is text. Switch on the block's type, and on the delta's type, so a thinking or tool block doesn't get mistaken for the answer.
Structured outputs and streaming coexist. If you constrain the model to a JSON schema, the response still streams as text_delta fragments that happen to assemble into valid JSON. You can show progress, but you generally can't parse the JSON until the block completes — same rule as tool arguments. For when to use schema-constrained output, see structured outputs explained and JSON mode vs structured outputs.
Backpressure and UI batching. Tokens can arrive faster than a browser can repaint. Updating the DOM on every single delta causes jank. Buffer a few fragments (or update on a short timer) before re-rendering. On the server side, if you're relaying the stream to a client, make sure your framework flushes each chunk rather than buffering the whole response — otherwise you've reintroduced the very latency streaming was meant to remove.
Raw HTTP, when you have no SDK. If you must consume the SSE wire format directly, the contract is: lines come as event: <type> then data: <json>, events are separated by blank lines, and a single logical event's data can span multiple data: lines that you concatenate. Read the body as a stream, split on double newlines, parse each data payload as JSON, and dispatch on the type field — the same six roles from the lifecycle above. It's doable, but reach for the SDK first; hand-rolling the framing is where most raw-HTTP streaming bugs live. The durable lesson across every provider and language: a stream is fragments plus framing — append the fragments by index, and read your metadata off the framing events, not the deltas.
FAQ
What is the difference between a content_block_delta and a message_delta?
A content_block_delta carries a small fragment of content — a piece of text (text_delta), a piece of tool-call JSON (input_json_delta), or reasoning (thinking_delta) — and fires many times as the model writes. A message_delta fires near the end and carries message-level metadata: the top-level stop_reason and the final cumulative output_tokens. One is the body, the other is the closing summary.
How do I reassemble streamed tokens into the full message?
Keep one buffer per content-block index. On every content_block_delta, append delta.text (for text) or delta.partial_json (for tool arguments) to the buffer matching that index. The concatenation of all text deltas in order is the full text. For tool arguments, wait until content_block_stop, then JSON.parse the joined string. Or skip all of this and call the SDK's final-message helper, which reassembles everything for you.
Where do I read token usage from a streamed response?
Usage is split across two events. input_tokens arrives on message_start (the prompt is already counted), where output_tokens is still near zero. The final output_tokens arrives on message_delta at the end. If you use the SDK's final-message helper, it sums these into one usage object for you, just like a non-streamed call.
Why does JSON.parse fail on a streamed tool call?
Because each input_json_delta is only a fragment of the arguments JSON, split at arbitrary points like {"ci then ty": "Par. No single fragment — and no partial buffer — is valid JSON until the block is complete. Concatenate every partial_json fragment for that block and parse the result only after content_block_stop fires.
How do I know if a streamed response was cut off?
Check the stop_reason on the message_delta event (or on the final reassembled message). end_turn means it finished naturally; max_tokens means it hit your output limit and the text is truncated — treat it as incomplete and retry with a higher max_tokens if you need the rest. tool_use means the model wants to call a tool and the turn continues.
Do I have to parse Server-Sent Events myself?
Almost never. The official SDKs parse the SSE wire format and hand you decoded event objects to switch on by type. Only parse raw SSE yourself if you're working in a language or environment without an SDK — and even then, the framing (multi-line data:, blank-line separators) is fiddly enough that an SDK is strongly preferred.