← Blog

Claude API Chunked Response Handling in JavaScript

2026-09-30 · 5 min read · SubToAPI Team

When you stream a response from the Claude API, the data doesn't arrive as one clean JSON object. It arrives as a series of raw byte chunks over an HTTP connection, and those chunks don't respect message boundaries. A single data: event can be split across two chunks, or one chunk can contain three complete events plus half of a fourth. If your JavaScript code assumes each chunk is a complete, parseable unit, it will work in testing and then randomly throw JSON.parse errors in production.

Handling this correctly means treating the incoming stream as a byte buffer, not a sequence of messages. You append each chunk to a buffer, split on the event delimiter, parse only complete events, and keep the leftover partial data for the next chunk. This article walks through that logic step by step, in plain JavaScript, so you understand exactly what's happening under the hood — whether you're calling Anthropic's API directly or a proxy like SubToAPI.

Why chunks don't map to messages

Claude's streaming API uses Server-Sent Events (SSE). Each event looks like this:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}

Events are separated by a blank line (\n\n). The underlying TCP/HTTP transport has no concept of "events" — it just moves bytes. The browser's fetch ReadableStream (or Node's stream) hands you whatever bytes happened to arrive in that network read, which could be a fragment of one event or several events concatenated.

This is true regardless of provider. It's a property of streaming over HTTP, not something specific to Claude.

The buffering pattern

Here's the core pattern for reading a chunked response in the browser or in Node 18+ with fetch:

async function streamResponse(url, options) {
  const response = await fetch(url, options);
  const reader = response.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");
    // The last element may be an incomplete event — keep it in the buffer
    buffer = events.pop();

    for (const rawEvent of events) {
      handleEvent(rawEvent);
    }
  }

  // Flush any trailing decoder state
  buffer += decoder.decode();
  if (buffer.trim()) handleEvent(buffer);
}

Two details matter here:

Parsing individual events

Each raw event block contains one or more lines. You care about the data: line:

function handleEvent(rawEvent) {
  const lines = rawEvent.split("\n");
  const dataLine = lines.find((l) => l.startsWith("data:"));
  if (!dataLine) return;

  const jsonStr = dataLine.slice(5).trim();
  if (jsonStr === "[DONE]") return; // some providers send a sentinel

  let payload;
  try {
    payload = JSON.parse(jsonStr);
  } catch (err) {
    // This should not happen if buffering is correct — log and skip
    console.error("Failed to parse event:", jsonStr, err);
    return;
  }

  switch (payload.type) {
    case "content_block_delta":
      process.stdout.write(payload.delta.text ?? "");
      break;
    case "message_stop":
      console.log("\n[stream complete]");
      break;
  }
}

Notice the try/catch around JSON.parse. If your buffering logic is correct, you should never hit that catch block — if you do, it's a signal that something upstream is splitting events incorrectly, not that Claude sent malformed JSON.

Common bugs to watch for

Splitting on \n instead of \n\n. SSE events are delimited by a blank line, not a single newline. Splitting on \n alone breaks multi-line data payloads and causes intermittent parse failures that are hard to reproduce.

Ignoring stream: true in the decoder. This causes garbled characters specifically with non-Latin text, which is why it often slips through testing done only in English.

Reusing the reader after an abort. If a user cancels a request mid-stream, make sure you call reader.cancel() and don't attempt further reader.read() calls — this throws in most environments.

Assuming one content_block_delta per token. Claude may batch multiple characters into a single delta event, or split a single word across two events. Never assume delta boundaries align with word or token boundaries — always concatenate and render the accumulated text, not each delta in isolation as if it were a full word.

Handling it without writing the parser yourself

If you're building this into a product rather than a one-off script, it's worth weighing whether to maintain this buffering/parsing logic yourself across every client (browser, mobile, backend workers) or centralize it. SubToAPI exposes the same Claude streaming format over https://api.subtoapi.app/v1/messages with stream: true, and the streaming docs include working examples for both browser fetch and Node.js consumers, plus guidance on reconnect handling and event types. The Messages API reference documents every event type you'll see in the stream so you're not guessing at the payload.type switch statement. If you're just getting started, the quickstart walks through a full request/response cycle with a generated API key from signup.

Whether you consume the raw Anthropic API or a gateway, the buffering logic above doesn't change — SSE parsing is a transport-level concern, not a provider-specific one.

questions

Why does JSON.parse sometimes fail on a stream chunk but work fine on retry? Because a single network read can cut an SSE event in half. If you parse each raw chunk instead of buffering until you have a complete \n\n-delimited event, you'll intermittently try to parse incomplete JSON. Fix the buffering, not the parsing.

Do I need a different approach for Node.js vs the browser? The core buffer-and-split logic is identical. The difference is how you get the reader: browsers use response.body.getReader() from fetch, while Node's fetch (18+) supports the same Web Streams API, or you can use response.body as a Node.js Readable and listen for data events instead.

Can I just use response.text() and parse the whole thing at the end? Only if you don't need incremental updates — but then you're not really streaming, you're just waiting for the full response before showing anything, which defeats the purpose of using a streaming endpoint in the first place.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →