Claude API Streaming with Server-Sent Events Explained
When you set "stream": true on a Claude Messages API call, the response isn't one JSON blob — it's a stream of Server-Sent Events (SSE) delivered over a single HTTP connection as the model generates tokens. Each event arrives as a small chunk of text prefixed with event: and data: lines, and your client reads them incrementally instead of waiting for the full completion.
This matters for two reasons: latency (you can start rendering text the moment the first tokens arrive, instead of waiting several seconds for a long response) and UX (typing/streaming effects, progress indicators, and the ability to let users stop generation mid-response). Below is how the protocol actually works, what event types to expect, and how to parse them correctly in both curl and JavaScript.
How SSE works over HTTP
Server-Sent Events is a plain-text, one-way streaming protocol built on top of a normal HTTP response. The server sets Content-Type: text/event-stream and keeps the connection open, writing events as they become available:
event: message_start
data: {"type":"message_start","message":{"id":"msg_01...","role":"assistant","content":[]}}
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: message_stop
data: {"type":"message_stop"}
Every event is a blank-line-separated block. The event: line names the event type, and data: carries a JSON payload. No polling, no websockets, no client-side reconnection logic required for a single request — just read the response body as it streams in.
Event types you'll actually handle
The Claude Messages API emits a fairly small, predictable set of event types during a streamed response:
message_start— the response begins, includes the message shell (id, role, empty content, usage so far)content_block_start— a new content block begins (text, or a tool_use block)content_block_delta— incremental text or partial JSON for a tool call argumentcontent_block_stop— the current block is completemessage_delta— top-level fields likestop_reasonand updatedusage(output token counts)message_stop— the response is fully doneping— periodic keep-alive, safe to ignoreerror— something went wrong mid-stream (rate limit, overload, etc.)
For tool use, content_block_delta events carry input_json_delta chunks — partial fragments of the tool call's JSON arguments that you concatenate and parse once the block stops. This is covered in more depth in tool use workflows.
Streaming with curl
curl handles SSE natively since it just prints the raw HTTP body:
curl -N https://api.subtoapi.app/v1/messages \
-H "Authorization: Bearer $SUBTOAPI_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4",
"max_tokens": 512,
"stream": true,
"messages": [
{"role": "user", "content": "Write a haiku about databases."}
]
}'
The -N flag disables curl's output buffering so you see events as they arrive instead of all at once at the end. This is the fastest way to sanity-check a streaming integration before writing client code.
Parsing SSE in JavaScript
Browsers have a built-in EventSource API, but it only supports GET requests and can't set custom headers — which rules it out for authenticated POST requests like Claude API calls. In practice you stream with fetch and read the body manually:
const response = 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",
max_tokens: 512,
stream: true,
messages: [{ role: "user", content: "Write a haiku about databases." }]
})
});
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");
buffer = events.pop(); // keep incomplete trailing chunk
for (const raw of events) {
const dataLine = raw.split("\n").find(l => l.startsWith("data:"));
if (!dataLine) continue;
const payload = JSON.parse(dataLine.replace("data:", "").trim());
if (payload.type === "content_block_delta" && payload.delta?.text) {
process.stdout.write(payload.delta.text);
}
if (payload.type === "message_stop") {
console.log("\n--- done ---");
}
}
}
The key detail is buffering: network chunks don't align with SSE event boundaries, so you accumulate text and split on the double-newline delimiter, keeping any incomplete trailing fragment for the next read. Skipping this step is the most common source of "JSON.parse crashes randomly" bugs in streaming code.
Server-side vs. client-side streaming
If you're building a product that streams Claude responses to a browser, you generally don't want to expose your API key to the frontend at all. The typical pattern is: your backend calls the Claude-compatible API with the real key and re-streams the SSE events to the browser over its own connection (or a websocket), applying whatever auth and rate limiting you need in between.
This is exactly what SubToAPI is built for — one sub_live_... application key per app instead of raw Anthropic credentials scattered across services, with the same streaming semantics described above. Full request/response shapes are in the Messages API docs and streaming-specific details are in the streaming docs. If you're setting this up for the first time, the quickstart walks through the first authenticated call end to end, and plans start at €9/month with a free trial at signup.
Questions
Does streaming cost more than a non-streamed response? No. Token usage and pricing are based on input and output tokens, not on whether the response was streamed. Streaming only changes delivery timing, not billing.
Can I cancel a stream mid-response? Yes — closing the underlying HTTP connection (aborting the fetch request or the reader) stops the server from sending further events. Anthropic still bills for tokens generated up to that point.
Why doesn't the browser's native EventSource work for this? EventSource only supports GET requests with no custom headers, but Claude API calls require a POST body and an Authorization header. Use fetch with manual stream reading instead, as shown above.