Concept
A non-streaming client.messages.create() call waits for the entire response to finish generating before returning anything to your code, for a long answer, that can be many seconds of a blank screen. Streaming instead delivers the response as a sequence of incremental Server-Sent Events (SSE) as the model generates each piece, which is what makes the token-by-token "typing" effect you see in every production AI chat UI possible.
The exact event sequence
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: "Explain TCP handshakes in two sentences." }],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
process.stdout.write(event.delta.text);
}
}
const finalMessage = await stream.finalMessage();
console.log("\nTokens used:", finalMessage.usage.output_tokens);Six event types make up a normal streamed turn, always in this order:
const stream = client.messages.stream({model: 'claude-opus-4-8',max_tokens: 1024,messages: [{ role: 'user', content: 'Explain TCP handshakes' }],});
…
Calling .stream() opens a long-lived connection instead of waiting for one full response. Nothing has arrived yet, the UI should show a pending state, not a blank one.
| Event | Fires | What it carries |
|---|---|---|
message_start | Once, first | Message metadata, id, model, empty usage. No text yet. |
content_block_start | When a new block begins | The block's type (text, tool_use, thinking) |
content_block_delta |
The client's job at the delta stage is nothing more than string concatenation: append delta.text to whatever's already rendered. Never replace the rendered text, deltas are fragments, not full snapshots.
Why streaming is required, not just nice-to-have, above a size threshold
Non-streaming requests are held open by both the SDK's HTTP client and (depending on your deployment) an infrastructure-level proxy timeout. For any response likely to exceed roughly 16,000 output tokens, a non-streaming call risks hitting one of those timeouts before the model finishes, the request simply fails, even though the model would have eventually produced a complete answer. Streaming sidesteps this because the connection is actively delivering bytes the whole time, so it's never "idle" long enough to trip a timeout.
// A long-form generation task, must stream, not just "may want to"
const stream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 64000, // well above the ~16K non-streaming safety threshold
messages: [{ role: "user", content: "Write a detailed technical design doc for..." }],
});Wiring a stream to a real UI: Server-Sent Events over HTTP
In a browser-facing app, the pattern is: your backend route opens a Claude stream, and re-emits it to the browser as its own SSE response (or you adapt it to a ReadableStream for fetch-based consumption). The browser never talks to Anthropic directly (see the SDK integration topic for why).
// app/api/chat/route.ts, Next.js Route Handler streaming back to the browser
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export async function POST(req: Request) {
const { prompt } = await req.json();
const claudeStream = client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 4096,
messages: [{ role: "user", content: prompt }],
});
const encoder = new TextEncoder();
const
On the client, a simple fetch with a readable-stream body reader is enough to render tokens as they arrive, no special browser API beyond what fetch already provides for reading a streamed response body incrementally.
A streamed turn that pauses for a tool call
Streaming and tool use compose, a streamed response can include ordinary text, then a tool_use block whose input fills in incrementally via input_json_delta fragments, then stop with stop_reason: "tool_use" instead of "end_turn".
// content_block_start, index 0, type: 'text'// a few text_delta events render normally
Let me check the weather for you.
Claude can stream ordinary explanatory text before deciding to call a tool, this is not an error state, just an earlier content block in the same message.
The critical detail: input_json_delta fragments are partial JSON strings. Concatenate them as you receive them, but only call JSON.parse() on the fully accumulated string once content_block_stop fires for that block, parsing a partial fragment mid-stream will throw, since {"locat is not valid JSON on its own.
Try It
Predict what the rendered UI text looks like after these three deltas arrive, in order, before checking the solution.
// delta 1: { type: "text_delta", text: "The " }
// delta 2: { type: "text_delta", text: "quick brown " }
// delta 3: { type: "text_delta", text: "fox." }If a beginner's code does renderedText = event.delta.text (assignment) instead of renderedText += event.delta.text (concatenation) inside the loop, what does the final rendered UI show?
Solution
"fox.", just the last delta, with everything before it silently overwritten. Each content_block_delta event carries only its own small text fragment, not the full accumulated text so far, the client is responsible for concatenating every fragment itself. Using = instead of += is one of the most common streaming bugs: it looks correct in a quick manual test (since the LAST delta happens to contain the tail of a real sentence, which can look plausible on its own), but it silently discards every earlier fragment, producing truncated or nonsensical output for any response longer than a single delta chunk.
Implement It Yourself
Build a minimal hand-rolled SSE line parser, the actual mechanism a fetch-based streaming client (or the SDK internally) uses to turn a raw byte stream into individual events, without any external streaming library.
async function parseSSEStream(response: Response, onEvent: (eventType: string, data: unknown) => void) {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer +=
This is close to the real mechanism: SSE frames are text, delimited by blank lines, each with an event: line and a data: line, parsing them by hand makes it obvious why buffering an incomplete trailing frame (the buffer = frames.pop() line) matters, since a chunk boundary from the network can split one logical frame across two reader.read() calls.
Under the Hood
The non-blocking, incremental nature of consuming a stream, for await (const event of stream) never blocks the rest of your server process while waiting for the next chunk, is the same asynchronous I/O model that underlies The Node.js Event Loop and, more generally, The JavaScript Event Loop: a stream is conceptually a sequence of deferred callbacks/microtasks resolving over time, not a single synchronous blocking read. On the wire, re-emitting Claude's stream to the browser as your own SSE response is exactly the request/response streaming model covered in Building an HTTP Server in Node.js, your Route Handler is itself acting as an HTTP server sending a chunked, long-lived response body.
Common Mistakes
1. Assigning instead of concatenating rendered text
// ❌ overwrites everything each delta, final UI shows only the LAST fragment
let rendered = "";
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
rendered = event.delta.text; // should be +=
}
}Deltas are fragments, not cumulative snapshots, always append, never replace.
2. Parsing input_json_delta fragments as JSON individually
// ❌ throws, partial JSON fragments aren't valid JSON on their own
for await (const event of stream) {
if (event.delta?.type === "input_json_delta") {
const parsed = JSON.parse(event.delta.partial_json); // "{\"locat" is not valid JSON
}
}Accumulate every partial_json fragment into one string across the whole block, and only call JSON.parse() once, after content_block_stop fires for that block.
3. Treating a non-streaming request as always safe regardless of expected output length
// ❌ risks an HTTP timeout for a long-form generation task
const response = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 64000, // large expected output, but NOT streaming
messages: [{ role: "user", content: "Write an exhaustive technical spec for..." }],
});Above roughly 16,000 expected output tokens, use client.messages.stream() instead, a non-streaming call that large risks failing on an HTTP timeout partway through generation, wasting the tokens the model already produced.
Best Practices
- Always concatenate
delta.text, never assign, a stream's rendered output is the sum of every fragment received so far, not just the latest one. - Buffer
input_json_deltafragments and parse only aftercontent_block_stop, partial JSON is not valid JSON. - Use
client.messages.stream()for any response that might exceed ~16K output tokens, this isn't an optimization, it's what prevents an otherwise-successful generation from failing on an HTTP timeout. - Call
stream.finalMessage()(or the equivalent await pattern) at the end to get the fully-assembledMessageobject, includingusageandstop_reason, instead of hand-tracking every field yourself.
Performance Tips
- Buffer a handful of tokens client-side before triggering a DOM re-render, rather than re-rendering on every single delta, many chat UIs batch deltas into ~50, 100ms render ticks to avoid excessive reflow on very fast streams.
- Prefer the SDK's
stream.on("text", ...)event (where available) over manually filtering every rawcontent_block_delta, it hands you just the delta string directly. - Reuse one long-lived HTTP connection per turn rather than polling, SSE's whole design point is to avoid the overhead and latency of a request-per-update polling loop.
