Claude API Server-Sent Events Streaming Explained
When you stream a response from the Claude API, you're not getting a WebSocket connection or a custom binary protocol — you're getting Server-Sent Events (SSE), a standard HTTP mechanism for pushing a sequence of text events from server to client over a single long-lived connection. If you're trying to understand how that stream is structured, what event types Claude sends, and how to parse them without a library, this is what you need to know.
SSE is old, boring, and reliable: it's just HTTP with Content-Type: text/event-stream, the connection stays open, and the server writes data: ... lines as they become available. No handshake negotiation, no binary framing, no extra ports to open. That's exactly why Claude (and most LLM APIs) use it for streaming — it works through standard proxies, load balancers, and browser fetch/EventSource implementations without special infrastructure.
What an SSE stream from Claude actually looks like
Set "stream": true in your request body and the response body becomes a sequence of data: lines instead of a single JSON payload. A raw chunk looks like this:
event: message_start
data: {"type":"message_start","message":{"id":"msg_01...","role":"assistant","content":[]}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
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"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}
event: message_stop
data: {"type":"message_stop"}
Each event is a named type followed by a JSON payload, separated by a blank line. There's no ambiguity about where one event ends and the next begins — the blank line is the delimiter, which is the whole point of the SSE spec.
The event types you'll see
Claude's stream always follows the same lifecycle:
message_start— the initial message object with empty content, plus initial usage info.content_block_start— a new content block begins (text, tool use, or thinking block).content_block_delta— incremental content. For text this istext_delta, for tool calls it'sinput_json_delta(partial JSON fragments you concatenate).content_block_stop— the current content block is complete.message_delta— top-level changes likestop_reasonand cumulative output token usage.message_stop— the stream is finished.ping— a keep-alive event with no payload you need to act on; ignore it but don't treat it as an error.error— an error occurred mid-stream (e.g. overloaded_error); you should stop consuming and handle it.
If you're implementing tool use over streaming, input_json_delta events give you the tool arguments as a fragmented JSON string — you have to buffer and concatenate the fragments per index before parsing the final JSON. This is covered in more depth in /docs/tools, but the core SSE mechanics are the same as text streaming.
Parsing SSE manually
If you're not using an SDK, parsing SSE by hand is straightforward but has a few gotchas:
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-sonnet-4-5',
max_tokens: 1024,
stream: true,
messages: [{ role: 'user', content: 'Write a haiku about SSE.' }],
}),
});
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 lines = buffer.split('\n');
buffer = lines.pop(); // keep incomplete line for next chunk
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = JSON.parse(line.slice(6));
if (payload.type === 'content_block_delta') {
process.stdout.write(payload.delta.text ?? '');
}
}
}
}
The common mistakes here: assuming each network chunk is exactly one event (it isn't — chunks can split mid-event or contain multiple events), forgetting to buffer partial lines, and not handling the ping and error event types explicitly. Browsers offer a native EventSource API, but it only supports GET requests and can't send custom headers or a JSON body, so it's not usable directly against Claude's POST-based streaming endpoint — you need fetch with a readable stream reader, or a library that wraps this for you.
Why teams put a layer in front of raw SSE
Raw SSE parsing works fine for a single client, but once you're serving streamed responses to multiple users from your own backend, you run into the same operational problems every LLM integration eventually hits: reconnect logic when a stream drops mid-response, per-application usage accounting from message_delta token counts, and rotating credentials without redeploying every client.
SubToAPI sits in front of your Claude access and re-exposes it as sub_live_... API keys with the same SSE streaming contract, so your existing parsing code doesn't change — you just point it at https://api.subtoapi.app/v1/messages instead. You also get per-key usage metadata and team seats, which matters once more than one person or service is consuming the stream. See /docs/streaming for the exact endpoint and headers, or /docs/quickstart to get a key from /signup.
questions
Is Claude's streaming API WebSockets or SSE? It's SSE (Server-Sent Events) over standard HTTP with Content-Type: text/event-stream. There's no WebSocket endpoint — you make a normal POST request with "stream": true and read the response body as a stream.
Can I use the browser's native EventSource for Claude streaming? No. EventSource only supports GET requests without custom headers, but Claude's endpoint requires a POST with a JSON body and an API key header. Use fetch with a ReadableStream reader instead, or an SDK that handles this.
What happens if the SSE connection drops mid-response? You'll stop receiving content_block_delta events without a message_stop. Your client should detect the incomplete stream (e.g., via a timeout) and either resume with a fresh request or fall back to non-streaming mode — Claude's API doesn't support resuming a partial stream from a cursor.