Claude API Streaming with Server-Sent Events: A Guide
When you stream a response from the Claude API, you're not getting one big JSON blob back — you're getting a sequence of server-sent events (SSE), a plain-text protocol where the server pushes small chunks of data over a single long-lived HTTP connection as they become available. This is what makes tokens appear on screen one at a time instead of all at once after a long wait.
If you're implementing this yourself, the core task is: open a POST request with stream: true, read the response body as a stream (not as JSON), split it into individual SSE events, and react to each event type as it arrives. This article walks through exactly how that works, what the event types mean, and the common mistakes that break streaming implementations in production.
How SSE Works Over HTTP
Server-sent events are just text sent over a normal HTTP response with the header Content-Type: text/event-stream. 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. The data: field contains a JSON payload specific to the event type. Your client reads the response body incrementally — as bytes arrive, you buffer them, split on double newlines, and parse each complete event.
Because it's just HTTP, no special libraries are required. fetch with a readable stream, or curl with -N to disable buffering, both work fine.
A Minimal curl Example
curl -N https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-4-1",
"max_tokens": 512,
"stream": true,
"messages": [{"role": "user", "content": "Write a haiku about streams"}]
}'
You'll see a sequence of events roll past in your terminal: message_start, several content_block_delta events (one per token or small chunk of text), content_block_stop, message_delta, and finally message_stop. That sequence is the entire lifecycle of a streamed response.
Parsing SSE in JavaScript
fetch doesn't parse SSE for you — it gives you a raw byte stream. Here's a bare-bones parser:
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-opus-4-1",
max_tokens: 512,
stream: true,
messages: [{ role: "user", content: "Explain SSE in one sentence" }],
}),
});
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 tail for next chunk
for (const raw of events) {
const line = raw.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const payload = JSON.parse(line.replace("data:", "").trim());
if (payload.type === "content_block_delta") {
process.stdout.write(payload.delta.text);
}
}
}
The key detail: never assume a single read() call gives you a complete event. TCP chunks and SSE events don't line up — you must buffer and split on the delimiter yourself, keeping any trailing partial event for the next read.
Event Types You Need to Handle
message_start— the response has begun; contains initial metadata like model and usage placeholders.content_block_start— a new content block (text or tool use) is starting.content_block_delta— the actual incremental content — text tokens or partial JSON for tool calls.content_block_stop— the current block is finished.message_delta— updates to top-level fields, notablystop_reasonand finalusagetoken counts.message_stop— the stream is done.ping— keep-alive, safe to ignore.error— something went wrong mid-stream (overload, rate limit); you should stop and surface this to the user.
If you're streaming tool calls, the JSON arguments arrive as fragments across multiple content_block_delta events and must be concatenated before parsing — see the tool use docs for how this is handled on the API side.
Common Pitfalls
Buffering by a proxy or CDN. If you put Claude API traffic behind certain reverse proxies or load balancers, they may buffer the response and deliver it all at once, defeating the purpose of streaming. Disable buffering for this route explicitly (e.g. proxy_buffering off in nginx).
Not handling disconnects. Long streams can drop mid-response due to network blips. Your client should detect an incomplete stream (no message_stop received) and retry the request rather than silently truncating output.
Treating stream: true responses as JSON. A common bug is calling .json() on a fetch response that's actually a stream — this will hang or throw. You need the raw body reader as shown above.
Ignoring usage in message_delta. Streaming responses report final token usage only in the last message_delta event, not upfront. If you're tracking cost or quota per request, read from there.
Where SubToAPI Fits
Implementing an SSE parser, handling reconnects, and tracking usage across every streamed request is the kind of plumbing that's easy to get subtly wrong and tedious to maintain across a team. SubToAPI exposes Claude as a standard HTTPS API with the same streaming semantics — stream: true returns SSE exactly as described above — but adds application-level API keys, per-key usage metadata, and team seat management on top, so you're not reimplementing auth and logging around every streaming endpoint. See the streaming docs or the quickstart to see a working example end to end, or check pricing if you're evaluating it for a team.
Questions
Does streaming change the total cost of a Claude API request? No. Token usage and cost are the same whether you stream or wait for the full response — streaming only changes delivery, not billing.
Can I cancel a stream partway through? Yes. Closing the underlying HTTP connection (aborting the fetch or closing the reader) stops the server from sending further tokens, though you may still be billed for tokens generated up to that point.
Why am I getting the full response at once instead of incremental chunks? This is almost always a buffering issue — either your HTTP client is waiting for the full body before returning, or an intermediary proxy is buffering the response. Confirm stream: true is set and that nothing between you and the API buffers text/event-stream responses.