01 · Foundations
Why Stream At All
A chat completion is not one value that appears at time T. It is a sequence of tokens produced at roughly a constant rate. A normal JSON endpoint hides that: the server generates for ten seconds, buffers every byte, then hands the client a finished object. The user watches a spinner for the entire generation.
Streaming changes nothing about how fast the model runs. It changes when the first byte is useful. Generation runs at maybe 60 tokens per second; a person reads at roughly 4 or 5 words per second. Once the first sentence is on screen the reader is the bottleneck, not the model — so the perceived wait collapses from total generation time to time to first token.
The metric that matters
Optimise
TTFT (time to first token), not total latency. A response that finishes in 12s but starts in 300ms feels faster than one that finishes in 8s and starts in 8s.
Three Things You Get For Free
⏱
No request timeouts
Long outputs — up to 128K tokens on current models — can exceed an HTTP idle timeout. The SDKs require streaming above a certain max_tokens for exactly this reason: bytes keep flowing, so nothing between you and the API decides the connection is dead.
■
A real stop button
With a buffered request there is nothing to stop — the work is already done when you find out about it. A stream is a live connection: closing it cancels generation and stops the meter.
◈
Progressive tool calls
Tool arguments stream as partial JSON. You can render Searching for… the moment the tool name arrives, rather than after the whole call is assembled.
01 · Foundations
The SSE Wire Format
SSE is not a protocol so much as a five-rule text convention carried over an ordinary HTTP response body. There is no handshake and no framing layer — the response never ends until the server closes it.
wire · text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
event: token
data: {"text":"Streaming "}
event: token
data: {"text":"is just "}
: this is a comment line — proxies see bytes, clients ignore it
event: done
data: {"usage":{"output_tokens":42}}
the five fields
field meaning
───────── ──────────────────────────────────────────────
data: the payload. Repeat the field to send a multi-
line value — the client joins them with "\n".
event: names the event. EventSource dispatches it as
a custom event type; a fetch-based reader must
parse it out by hand.
id: last-event-id. EventSource echoes it back in
the Last-Event-ID header when it reconnects.
retry: reconnect delay in ms (EventSource only).
: comment / keepalive. Cheapest way to push bytes
through an idle proxy.
FRAMING RULE — one blank line (\n\n) terminates an event.
Everything else is just lines of UTF-8.
The one rule that bites
Events are separated by a
blank line, not a newline. And TCP chunk boundaries have nothing to do with event boundaries — a single
read() can hand you two and a half events. Buffer, split on
\n\n, and keep the remainder.
Why The Response Headers Matter
📄
CONTENT-TYPE
text/event-stream
🚫
CACHE-CONTROL
no-cache, no-transform
📏
NO LENGTH
chunked, not fixed
no-transform is the underrated one. It tells intermediaries not to re-encode the body — which is how a well-meaning gzip layer turns your carefully flushed 40-byte events into one buffered blob delivered at the end.
02 · The Model Stream
Anthropic Event Types
A streamed completion is a nested structure, not a flat token feed. A message contains an ordered list of content blocks — a thinking block, then a text block, then maybe a tool call — and each block opens, emits deltas, and closes. Six event types describe the whole thing.
raw SSE · POST /v1/messages
event: message_start
data: {"type":"message_start","message":{"id":"msg_01...","role":"assistant","content":[],"usage":{"input_tokens":1214,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: ping
data: {"type":"ping"}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}
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"}
▶
message_start
Once, first. Carries the message id, the role, and usage.input_tokens — so you can log prompt cost before a single output token exists.
[
content_block_start
A new block opens at index. Its content_block.type tells you what is coming: text, thinking, tool_use, server_tool_use.
•
content_block_delta
The payload event, many per block. The delta.type discriminates: text_delta, thinking_delta, input_json_delta, signature_delta, citations_delta.
]
content_block_stop
The block at index is complete. This — not message_stop — is when a tool call is finally safe to parse.
Δ
message_delta
Message-level updates near the end: delta.stop_reason and cumulative usage.output_tokens. If you bill or log per turn, this is your event.
■
message_stop
Once, last. A terminator, nothing more — the interesting fields already arrived on message_delta.
Two Events The Type Union Does Not Mention
The wire also carries ping and error. The official SDKs skip ping silently and raise error as an APIError, which is why neither appears in the SDK event union. If you write your own parser against the raw endpoint, you must handle both yourself.
Errors arrive after 200 OK
The status line is sent before generation begins, so a mid-stream failure — an overload, a timeout, a disconnect at token 400 — cannot be a 500. It arrives as
event: error inside a response that already claimed success. Any layer you build on top has to be able to report failure
in band.
ping is not decoration
Periodic pings keep bytes moving on a long thinking pause. Idle proxies and load balancers close connections that go quiet; the keepalive is what stops a 90-second reasoning turn from being reaped mid-flight.
02 · The Model Stream
Streaming with the SDK
You can consume the raw SSE endpoint directly, and the section above is enough to do it. In a TypeScript service there is no reason to: client.messages.stream() gives you the same events with types attached, plus accumulation, plus abort handling.
server · basic stream
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const stream = client.messages.stream({
model: 'claude-opus-5',
max_tokens: 64000,
messages: [{ role: 'user', content: 'Explain HTTP streaming.' }],
});
// Option A — the low-level event feed. Every raw SSE event,
// in order, exactly as the API emitted it.
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
process.stdout.write(event.delta.text);
}
}
// Option B — the helper. Emits only the text delta, plus a
// running snapshot of everything received so far.
stream.on('text', (delta, snapshot) => {
process.stdout.write(delta);
});
// Either way, the assembled Message is one await away.
// finalMessage() handles completion, error and abort for you —
// do NOT hand-roll it with new Promise().
const message = await stream.finalMessage();
console.log(message.usage.output_tokens, message.stop_reason);
Do not rebuild finalMessage()
Wrapping
.on() listeners in a
new Promise() to find out when the stream finished is the classic re-implementation.
await stream.finalMessage() already resolves on completion, rejects on error, and handles abort — and returns the fully assembled
Message, blocks and usage included.
Handling Every Block Type
A turn with thinking enabled and a tool available produces three interleaved block streams. The index is what keeps them apart — append by index, never to a single string.
server · full event switch
const stream = client.messages.stream({
model: 'claude-opus-5',
max_tokens: 64000,
// Without display:'summarized' the thinking blocks stream with
// EMPTY text — your UI shows a long silence, then a burst.
thinking: { type: 'adaptive', display: 'summarized' },
tools: [searchTool],
messages,
});
for await (const event of stream) {
switch (event.type) {
case 'content_block_start':
// index tells you WHICH block. A single turn can be
// thinking(0) → text(1) → tool_use(2).
open(event.index, event.content_block.type);
break;
case 'content_block_delta':
switch (event.delta.type) {
case 'thinking_delta':
appendThinking(event.index, event.delta.thinking);
break;
case 'text_delta':
appendText(event.index, event.delta.text);
break;
case 'input_json_delta':
// PARTIAL JSON. '{"que' is not parseable. Buffer it
// and JSON.parse only on content_block_stop.
bufferToolInput(event.index, event.delta.partial_json);
break;
}
break;
case 'content_block_stop':
close(event.index);
break;
case 'message_delta':
// stop_reason arrives here, not on message_stop.
finish(event.delta.stop_reason, event.usage.output_tokens);
break;
}
}
input_json_delta is not valid JSON
Tool arguments stream as fragments:
{"que, then
ry":"htt, then
p strea. Concatenate into a buffer and call
JSON.parse only at
content_block_stop. Parsing early throws on every token but the last one.
The silence bug: thinking display
On current models the default is
display: “omitted” — thinking blocks still stream, but with empty text. A UI that renders deltas shows nothing at all while the model reasons, then a burst of output. If you surface reasoning to users, set
thinking: { type: "adaptive", display: "summarized" } explicitly. Thinking is billed identically either way; only visibility changes.
Streaming Straight Through
The SDK can also hand you a ReadableStream of its own events via stream.toReadableStream(), which a browser can rehydrate with MessageStream.fromReadableStream(). That is the fastest path to a working prototype — and the wrong default for production, for reasons the next section gets into.
03 · Server ⇄ Client
The Full Sequence
Three participants, two HTTP connections, one direction of travel. Everything below the fourth arrow happens while both connections are still open — the response bodies are in flight the entire time.
Step by Step
What happens at each arrow
1 · POST /api/chat
The browser sends the whole conversation. This is a normal request with a normal body — which is precisely why EventSource cannot be used on the way back.
2 · POST /v1/messages
Your server opens the upstream request with stream: true. Your API key never leaves the server; the browser only ever talks to your origin.
3 · 200 OK from the API
Headers come back as soon as the request is accepted, long before the first token. The response body is now an open pipe.
4 · 200 OK to the browser
Do not wait for a token to reply. Returning the Response with an open ReadableStream flushes headers immediately — this arrow is your TTFB, and it should land in tens of milliseconds.
5 · content_block_delta
The first real token. Everything before this was setup; the clock the user feels starts here.
6 · event: token
Your server translates the upstream event into your own narrow protocol and pushes one SSE frame. One inbound event does not have to mean one outbound frame — filtering and coalescing both live here.
7 · paint
The client appends the delta and re-renders. At 100 tok/s this fires every 10ms, which is where the coalescing advice in the client section comes from.
8 · message_delta / message_stop
stop_reason and final output_tokens arrive on message_delta. message_stop is just a terminator.
9 · event: done
Send the summary the UI needs — usage, stop_reason, a persisted message id — then close the controller. The FIN is what tells the client reader it is finished.
The proxy is not a pass-through
It is tempting to pipe the upstream body straight to the client. Do not. Your server is the boundary where the model protocol becomes
your protocol — and where the API key, the system prompt, the tool list and the raw thinking content stop.
Piping raw API events
Leaks tool definitions and block structure to the client
UI breaks when the upstream event shape changes
Every model swap is a frontend change
Nowhere to inject your own events (saved, rate-limited, citation)
Re-emitting your own events
Three or four event names the UI actually uses
Provider details stay server-side
Filtering, redaction and coalescing have a home
Errors and metadata ride the same channel
03 · Server ⇄ Client
The Route Handler
A Route Handler returns a Web Response. Hand it a ReadableStream instead of a string and the runtime flushes headers immediately, then forwards each enqueue as it happens.
app/api/chat/route.ts
// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const encoder = new TextEncoder();
// Route Handlers are NOT cached by default — leave it that way.
// Never add `export const dynamic = 'force-static'` here.
export async function POST(request: Request) {
const { messages } = await request.json();
const upstream = client.messages.stream(
{
model: 'claude-opus-5',
max_tokens: 64000,
messages,
},
// When the browser goes away, so does the upstream request.
{ signal: request.signal }
);
const body = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) =>
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
try {
for await (const event of upstream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
send('token', { text: event.delta.text });
}
}
const final = await upstream.finalMessage();
send('done', {
stop_reason: final.stop_reason,
usage: final.usage,
});
} catch (err) {
// A mid-stream failure arrives AFTER 200 OK. You cannot
// change the status code — put the error in the stream.
send('error', { message: (err as Error).message });
} finally {
controller.close();
}
},
cancel() {
upstream.abort();
},
});
return new Response(body, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Content-Type-Options': 'nosniff',
'X-Accel-Buffering': 'no',
},
});
}
Four Details That Are Easy To Miss
🔓
Never force-static
Route Handlers are uncached by default, which is what you want. Adding export const dynamic = ‘force-static’ to a streaming route asks the framework to prerender an infinite response.
🧹
Implement cancel()
ReadableStream.cancel() fires when the consumer goes away. That is your hook to abort the upstream request instead of paying for tokens nobody will read.
🩹
Errors go in the body
Once new Response() is returned, the status code is settled. Catch upstream failures and emit an error event so the UI can show something better than a sentence that stops mid-word.
🔚
Always close()
A finally block that calls controller.close() is the difference between a client that finalises the turn and one that hangs on reader.read() forever.
Prove It Is Actually Streaming
terminal · verify the stream
# Watch the raw bytes arrive. --no-buffer is the whole point:
# without it curl holds output until the stream ends and you
# will swear streaming is broken.
curl -N --no-buffer http://localhost:3000/api/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"count to ten"}]}'
# Is a proxy buffering you? Compare time-to-first-byte against
# total time. If they are equal, something downstream buffered.
curl -o /dev/null -s -w 'ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
-N http://localhost:3000/api/chat -d @body.json
The one-line diagnosis
If
time_starttransfer is roughly equal to
time_total, nothing is streaming — something between your handler and curl buffered the whole body. Work outwards: framework, then compression middleware, then reverse proxy, then CDN.
03 · Server ⇄ Client
Consuming in React
Two jobs on the client: turn a byte stream into events, and turn events into renders without melting the main thread. They fail in different ways, so keep them apart.
1 · Bytes to Events
lib/sse.js · the parser
// The parser everyone gets wrong on the first try.
async function* readSSE(response, signal) {
const reader = response.body.getReader();
// stream:true — a UTF-8 character can be SPLIT across two
// network chunks. A fresh TextDecoder per chunk mangles it.
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Events are separated by a BLANK LINE, not a newline.
// Chunk boundaries fall wherever TCP feels like it, so
// keep the trailing partial event in the buffer.
let split;
while ((split = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let event = 'message';
const data = [];
for (const line of raw.split('\n')) {
if (line.startsWith(':')) continue; // comment
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).trim());
}
if (data.length) yield { event, data: JSON.parse(data.join('\n')) };
}
}
} finally {
reader.cancel();
}
}
Three bugs live in that function
Creating a fresh
TextDecoder per chunk corrupts any multi-byte character that straddles a boundary. Splitting on
\n instead of
\n\n shreds multi-line events. Dropping the buffer remainder silently loses the last partial event of every chunk — which usually only shows up under load, when chunks get bigger.
2 · Events to State
hooks/useChat.js
function useChat() {
const [messages, setMessages] = useState([]);
const abortRef = useRef(null);
async function send(text) {
const controller = new AbortController();
abortRef.current = controller;
setMessages((m) => [
...m,
{ role: 'user', content: text },
{ role: 'assistant', content: '', streaming: true },
]);
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, { role: 'user', content: text }] }),
signal: controller.signal,
});
// A 4xx/5xx still has a JSON body — check BEFORE reading as a stream.
if (!res.ok) throw new Error(await res.text());
try {
for await (const { event, data } of readSSE(res)) {
if (event === 'token') {
// Functional update: the closure over `messages` is stale
// by the second token.
setMessages((m) => {
const next = m.slice();
const last = next[next.length - 1];
next[next.length - 1] = { ...last, content: last.content + data.text };
return next;
});
} else if (event === 'error') {
throw new Error(data.message);
}
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
} finally {
setMessages((m) =>
m.map((msg, i) =>
i === m.length - 1 ? { ...msg, streaming: false } : msg
)
);
abortRef.current = null;
}
}
const stop = () => abortRef.current?.abort();
return { messages, send, stop };
}
Always use the functional update
setMessages(m => …), never
setMessages([...messages, x]). The closure captured when the request started is stale by the second token, and you will render a message that keeps losing its first half.
3 · One Paint Per Frame
At 100 tokens per second a naive implementation schedules 100 React renders per second, each one re-running a markdown parser over a string that keeps growing. Buffer the deltas and flush on an animation frame — the text still appears continuous, because 60fps is faster than anyone reads.
hooks/useTokenBuffer.js
// At 100+ tokens/sec, one setState per token is one React
// render per token. Coalesce to one paint per animation frame.
function useTokenBuffer(onFlush) {
const pending = useRef('');
const frame = useRef(0);
return (delta) => {
pending.current += delta;
if (frame.current) return;
frame.current = requestAnimationFrame(() => {
frame.current = 0;
const chunk = pending.current;
pending.current = '';
onFlush(chunk);
});
};
}
Two more UI details
Auto-scroll should stick to the bottom only while the user has not scrolled up — otherwise reading back through a long answer is impossible while it streams.
Markdown must tolerate half-written syntax: an unclosed code fence is the normal state of a streaming response, not an error.
04 · Production
Cancel & Backpressure
A stop button is not a UI affordance, it is a cost control. Generation continues until something cancels it, and every token generated is billed whether or not it was ever rendered. The abort has to travel the full length of the diagram.
cancellation, both ends
// CLIENT — the stop button
const controller = new AbortController();
fetch('/api/chat', { method: 'POST', signal: controller.signal, body });
stopButton.onclick = () => controller.abort();
// SERVER — request.signal fires when the socket drops, whether
// the user hit stop, closed the tab, or the network died.
export async function POST(request: Request) {
const upstream = client.messages.stream(params, { signal: request.signal });
request.signal.addEventListener('abort', () => {
// Tokens generated before this point are still billed —
// persist the partial turn so the next request has history.
void savePartial(upstream.currentMessage);
});
// ...
}
💸
Aborts still bill
Tokens produced before the cancel are charged. Cancelling early saves the rest — which is the whole point — but it is not a refund.
💾
Persist the fragment
You paid for those tokens and the user read them. Save the partial assistant turn, flagged as interrupted, so the next request has honest history.
🚪
Tab close counts
request.signal fires for a closed tab, a dropped network and a navigation away — not just the stop button. Wire the cleanup once and all four are covered.
🌊
Backpressure is real
If the consumer stops reading, the TCP window fills and enqueue stops being free. Generating into a start() loop with no consumer buffers in memory; a pull()-driven source only produces when asked.
Check stop_reason before you trust it
A stream that ends cleanly has not necessarily answered.
max_tokens means the response was truncated mid-thought and
refusal means the model declined. Both look exactly like success to a client that only listens for the close.
04 · Production
Buffering Pitfalls
Almost every “streaming does not work in production” bug is the same bug: something between your handler and the browser decided to collect the whole body first. Your code is fine locally and broken behind the CDN.
Work outwards from the handler
Compression middleware
gzip and brotli batch input to compress it. Either exclude text/event-stream or flush per event. Cache-Control: no-transform tells intermediaries to leave the body alone.
Reverse proxies
nginx buffers proxied responses by default. proxy_buffering off, or send the X-Accel-Buffering: no header and let nginx disable it per response.
A stray Content-Length
Setting it forces a fixed-length body. The response cannot be chunked, so it cannot be streamed.
Tiny first chunk
Some intermediaries wait for a few kilobytes before forwarding anything. A 2KB SSE comment line sent first ( : padding ) pushes past the threshold and unblocks the pipe.
Serverless duration caps
Streaming keeps bytes flowing but does not extend a platform maximum execution time. A long reasoning turn can still be killed mid-stream at the cap.
HTTP/1.1 connection limits
Six connections per origin. Multiple concurrent EventSource streams in several tabs will deadlock each other. HTTP/2 multiplexing removes the limit.
Dev tools lying to you
The Network panel often shows a streaming response as pending with no size until it completes. Trust curl -N over the browser inspector.
Bisect the path
Run
curl -N against the handler directly, then against the proxy, then against the public URL. The hop where TTFB jumps to match the total time is the hop that is buffering. It takes two minutes and replaces an afternoon of guessing.
04 · Production
Streaming Checklist
Click items to mark complete. Progress tracked per session.
COMPLETION
0 / 14 complete
Transport
Response sets text/event-stream, no Content-Length, and Cache-Control: no-cache, no-transformCritical
Compression and proxy buffering disabled on the streaming route (X-Accel-Buffering: no)Critical
Verified end-to-end with curl -N: TTFB is far below total timeVerify
Parsing
Client splits on the blank line, not on \n, and keeps the trailing partial event bufferedCritical
TextDecoder created once and used with { stream: true }Encoding
input_json_delta buffered and parsed only at content_block_stopTools
content_block index respected — thinking, text and tool_use are separate blocksBlocks
Failure & lifecycle
Mid-stream errors are sent as an in-band event — the status code was already 200Critical
AbortController wired from the stop button through to the upstream requestCancel
Partial assistant turns persisted on abort — those tokens were billedCancel
stop_reason checked: max_tokens and refusal are not end_turnVerify
UI
Token appends coalesced to one paint per animation framePerf
Auto-scroll sticks to the bottom only while the user has not scrolled upPerf
Markdown renderer tolerates unclosed fences and half-written tablesPerf