AI/TLDR

How to Parse a Streaming LLM Response: Events, Deltas, and Reassembly

Learn the actual event shapes a stream emits and how to accumulate deltas into a complete message, capture the stop reason, and read final token usage.

INTERMEDIATE12 MIN READUPDATED 2026-06-13

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.

Parsing Stream Events — illustration
Parsing Stream Events — aicertswpcdn.blob.core.windows.net

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 large max_tokens this 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.

EventFiresWhat you do with it
message_startOnce, firstGrab the message shell. Its usage already has input_tokens; output_tokens is still ~0 here.
content_block_startEach block opensNote the block's index and type (text, tool_use, thinking). Initialize a buffer for it.
content_block_deltaMany timesThe workhorse. Append delta.text (text), or delta.partial_json (tool args), to the buffer for that index.
content_block_stopEach block closesThat block's content is now complete. Safe to JSON.parse accumulated tool args here.
message_deltaNear the endCarries the top-level stop_reason and the final cumulative usage.output_tokens.
message_stopOnce, lastThe 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.

what the deltas look like for a tool call (conceptual)text
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.parse

Notice 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.

raw event handling with the SDK streamtypescript
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.

  • Input tokens are known up front — the prompt is already counted — so they ride on message_start. At that point output_tokens is still near zero because nothing has been generated yet.
  • Output tokens keep growing as the model writes, so the final count lands on message_delta at the end. That same message_delta carries the top-level stop_reason.
  • stop_reason tells 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), or refusal. Branch on this before trusting the reassembled text.
let the helper reassemble everything for youtypescript
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, summed

Common pitfalls

  • Parsing tool JSON mid-stream. Each input_json_delta is a fragment. Calling JSON.parse on one (or on a half-built buffer) throws. Concatenate every partial_json for that block, then parse once at content_block_stop.
  • Treating a max_tokens stop as a complete answer. If stop_reason is max_tokens, the model was cut off. The text looks finished but isn't. Check the stop reason before using the result; raise max_tokens and 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 by index, you'll merge a thinking block, a text block, and tool arguments into nonsense. Buffer per index.
  • Reading output_tokens from message_start. It's ~0 there. The real output count is on message_delta at 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 on event.type instead.
  • 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.

Further reading