Claude API Streaming: Parsing Chunked Responses Right
When you stream a Claude API response, you don't get one clean JSON blob back — you get a sequence of small chunks sent over an open HTTP connection, each one representing a partial update to the model's output. Parsing these correctly means handling Server-Sent Events (SSE) framing, reassembling text deltas in order, and dealing with event types you might not expect (tool calls, stop reasons, usage metadata). Get the parsing wrong and you'll see garbled text, dropped tokens, or a client that hangs waiting for a chunk that never arrives.
This article walks through exactly how chunked streaming responses are structured, how to parse them safely in Node and the browser, and the specific edge cases that trip people up: partial UTF-8 characters split across chunks, multi-event buffers, and reconnect handling.
How Claude's streaming response is framed
Claude's streaming API uses SSE over a standard HTTP connection with Content-Type: text/event-stream. Each event looks like this on the wire:
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}
Key things to understand about this framing:
- Each event is separated by a blank line. That blank line is the actual delimiter — not newlines within the chunk.
- A single TCP read/chunk from the socket can contain zero, one, or several complete events, plus a partial event at the end.
- Events arrive in order, but your parser still needs to buffer partial data until a full event (ending in
\n\n) is available.
This is the root cause of most "streaming parsing bugs": developers assume one data: write from the server equals one chunk at the TCP level. It doesn't. HTTP chunked transfer encoding and SSE framing are two separate layers — your code needs to handle both.
Parsing chunks correctly in Node.js
The safe pattern is: accumulate raw bytes in a buffer, split on double newlines, process complete events, and keep the remainder for the next read.
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "claude-sonnet-4-5",
stream: true,
max_tokens: 1024,
messages: [{ role: "user", content: "Write a haiku about parsing." }]
})
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop(); // keep the incomplete tail
for (const raw of events) {
const line = raw.split("\n").find(l => l.startsWith("data:"));
if (!line) continue;
const json = JSON.parse(line.replace("data:", "").trim());
if (json.type === "content_block_delta") {
process.stdout.write(json.delta.text);
}
if (json.type === "message_stop") {
console.log("\n--- stream complete ---");
}
}
}
Two details matter here that are easy to miss:
decoder.decode(value, { stream: true }) — passing stream: true tells TextDecoder to hold back any incomplete multi-byte UTF-8 sequence instead of corrupting it. Without this, emoji and non-Latin text get mangled when a character is split across two chunks.
Keeping the tail with buffer.split("\n\n").pop() — this is what prevents you from processing a half-written JSON object. If you parse every line as soon as it arrives without this buffering step, you'll get intermittent JSON.parse errors on larger responses, and they'll be nearly impossible to reproduce locally because they depend on network chunk boundaries.
Handling the different event types
A full Claude stream includes more than just text deltas. A parser that only looks for content_block_delta will work until the model calls a tool or you need usage numbers for billing. The event types you should branch on:
message_start— contains the initial message shell and model infocontent_block_start— signals the start of a content block (text or tool_use)content_block_delta— incremental text or tool input JSON fragmentscontent_block_stop— a block is completemessage_delta— carriesstop_reasonand incrementalusageupdatesmessage_stop— the stream is finished
If your application uses tool calling, tool input arrives as a stream of partial JSON strings inside content_block_delta events with type: "input_json_delta" — you accumulate the string fragments and JSON.parse the whole thing only once the block stops. Parsing it mid-stream will throw repeatedly, which is expected behavior, not a bug.
Reconnects and dropped connections
Chunked streams can drop mid-response — a proxy timeout, a flaky mobile connection, a server restart. Your parser needs a strategy for this independent of the JSON parsing logic:
- Track the last complete
content_block_deltaindex you processed. - On disconnect, decide whether to discard the partial response or request from where you left off.
- Set a read timeout on the stream separate from your overall request timeout, so a stalled connection doesn't hang your app indefinitely.
If you're building this yourself across multiple provider integrations, you end up re-solving the same buffering, UTF-8, and reconnect logic for each one. SubToAPI exposes Claude through a single streaming endpoint with the same SSE format described above, so the parsing code you write once works whether you're calling it from a Node service, a browser client, or a mobile backend. See the streaming docs for the full event reference and a working example, or start with the quickstart if you haven't set up an API key yet.
FAQ
Why does my JSON.parse fail intermittently on streamed chunks? You're almost certainly parsing raw chunk data directly instead of buffering until a full SSE event (ending in a blank line) has arrived. Fix it by accumulating bytes in a buffer and only parsing complete events.
Why do I see corrupted characters in streamed text? Multi-byte UTF-8 characters can be split across two network chunks. Use TextDecoder with { stream: true } so it holds incomplete byte sequences until the rest arrives, instead of decoding partial bytes immediately.
How do I get token usage data from a streaming response? Usage information arrives incrementally in message_delta events and is finalized in the last event before message_stop. Don't rely on a single final response object the way you would with a non-streaming call — accumulate usage fields as they update.