Claude API Streaming: A TypeScript Implementation Guide
Streaming responses from the Claude API in TypeScript means consuming a server-sent events (SSE) stream, parsing incremental JSON chunks as they arrive, and rendering text to your UI token by token instead of waiting for the full response. This guide covers the actual implementation: setting up the fetch request, parsing the event stream manually or with a client library, handling the different event types Claude sends, and dealing with errors mid-stream.
If you're building a chat interface, a CLI tool, or any product where users wait on a model response, streaming is not optional — a 5-second wait with no feedback feels broken, while the same 5 seconds with tokens appearing in real time feels fast. The implementation details below work whether you're calling Anthropic's API directly or a compatible gateway like SubToAPI.
How Claude's streaming protocol works
When you set "stream": true in your request, Claude responds with Content-Type: text/event-stream and sends a sequence of named events, each prefixed with event: and followed by a data: line containing a JSON payload. The typical sequence for a text response looks like:
event: message_start
data: {"type":"message_start","message":{...}}
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":" there"}}
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"}
Your TypeScript code needs to accumulate the text_delta fragments from content_block_delta events to reconstruct the full response, and watch for message_delta to capture the final stop_reason and token usage.
Implementing the fetch-based stream reader
Node.js 18+ and all modern browsers support the ReadableStream interface on fetch responses, so you don't need a special SDK to consume SSE manually:
async function streamClaude(prompt: string): Promise<string> {
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-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
if (!response.body) throw new Error("No response body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = "";
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() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const json = line.slice(6);
if (json === "[DONE]") continue;
const event = JSON.parse(json);
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
fullText += event.delta.text;
process.stdout.write(event.delta.text); // print as it arrives
}
}
}
return fullText;
}
The key detail developers get wrong: chunks from the network don't align with line boundaries. You must buffer partial lines and only process complete ones, carrying the remainder forward — that's what the buffer.split("\n") / lines.pop() pattern handles.
Turning it into an async generator
For real applications, wrapping the stream as an AsyncGenerator makes it composable with UI frameworks (React, SSE relays, CLI spinners):
async function* streamClaudeTokens(prompt: string): AsyncGenerator<string> {
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-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
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() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
yield event.delta.text;
}
}
}
}
// usage
for await (const chunk of streamClaudeTokens("Explain async generators")) {
process.stdout.write(chunk);
}
This pattern drops cleanly into an Express route (pipe chunks to the client), a React hook (append to state on each yield), or a WebSocket relay.
Error handling you shouldn't skip
Streaming introduces failure modes that non-streaming requests don't have:
- Mid-stream disconnects — the connection can drop after
message_startbut beforemessage_stop. Track whether you received a terminal event and retry the whole request if not; partial text is not safely resumable. errorevents — Claude can send anevent: errorwith a JSON payload mid-stream (e.g., overloaded errors). Checkevent.type === "error"in your parsing loop and surface it distinctly from a normal stop.- Rate limit headers — these only arrive on the initial response headers, not in the stream body, so read
response.headersbefore you start consuming the reader. - Abort support — pass an
AbortControllersignal intofetchso users can cancel generation (stop button in a chat UI) without leaking the connection.
When to use a managed layer instead
Hand-rolling SSE parsing is worth doing once so you understand it, but maintaining it across retries, reconnects, tool-use events, and usage tracking adds up. If you want streaming without owning the parsing layer, SubToAPI exposes the same /v1/messages streaming contract behind an application API key (sub_live_...), so your TypeScript client code is nearly identical to the example above — just swap the endpoint and auth header. See the streaming docs and quickstart for the full request/response shape, or check pricing if you're evaluating it for a team.
FAQ
Does Claude's streaming API use standard SSE, or something custom? It's standard text/event-stream with named events (message_start, content_block_delta, etc.), so any SSE-aware client or a plain fetch + ReadableStream reader works without special libraries.
Can I stream tool use (function calling) the same way? Yes — tool calls arrive as content_block_start/delta/stop events with type: "tool_use", and the JSON input streams incrementally as input_json_delta fragments you concatenate and parse once the block closes. See tool use docs for the full event shapes.
Why does my buffer approach sometimes drop or duplicate text? Almost always because chunks are processed without accounting for partial lines at chunk boundaries. Always buffer incomplete lines and only parse full data: lines, carrying any trailing partial line into the next read.