If your Claude-powered product feels sluggish, total throughput is almost never the culprit. Time to first token (TTFT) is. A non-streaming completion that returns 600 tokens after 8 seconds feels broken. The same 600 tokens, streamed, feel instant: the first words land in under 400 ms and the rest scrolls in like someone typing. Streaming does not make the model faster. It brings perceived latency down to roughly half of the total, and perceived latency is the number your users feel.
This guide covers how Claude streams over Server-Sent Events (SSE), the
Anthropic events you have to handle, and production patterns in
TypeScript (Next.js Edge runtime) and Python (httpx async). Every example
targets the Anthropic-compatible gateway from codexkey at
https://api.codexkey.online/v1, a drop-in replacement for
api.anthropic.com/v1.
TTFT versus total latency
Record two numbers when you benchmark a Claude call:
- TTFT: milliseconds until the first content delta arrives.
- Total: milliseconds until
message_stop.
For a Sonnet call that produces about 800 output tokens, TTFT is typically 300-600 ms and total is 4-8 seconds. Without streaming, users wait the full 8 seconds staring at a spinner. With streaming they see text after 400 ms and read along while it generates. Tokens per second (TPS) is identical either way. What you buy is perception, not throughput.
The SSE wire format
Server-Sent Events is a one-way HTTP streaming protocol. The response
carries Content-Type: text/event-stream, and the body is a series of
records separated by blank lines. One record looks like this:
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
Two rules trip everyone up:
- A record ends with two newlines (
\n\n), not one. - One logical event can span several
data:lines. The consumer joins them with\nand only then parses the JSON.
Most SDKs hide all of this. The moment you parse SSE by hand, whether in an Edge Worker, in Go or while debugging, you have to follow both rules yourself.
Anthropic event types
Claude's stream emits a small, well-defined set of events. Handle each one explicitly, and do not assume an order beyond what the spec guarantees.
message_start: the opening message envelope withid,modeland a zeroedusageblock. This is the place to capture the message id for logs.content_block_start: a new content block begins. Its type istext,tool_use,thinkingorredacted_thinking. The index matters when the model emits several blocks.content_block_delta: the incremental payload. Text arrives astext_delta. Tool calls arrive asinput_json_delta, partial JSON fragments that you concatenate before parsing.content_block_stop: the block is finished.message_delta: updates to the message as a whole, above allstop_reason(end_turn,max_tokens,tool_use,stop_sequence) and the finalusagecounters.message_stop: the terminal event. Close your reader here.ping: a keep-alive sent every ~15 seconds. Ignore the payload, but do not close the stream. Pings exist so that reverse proxies don't kill an idle connection.error: yes, errors can arrive mid-stream as an ordinary SSE event (overloaded model, content policy stop, upstream timeout). Your handler must treat an error event exactly like a thrown error.
TypeScript: Next.js Edge Route Handler
The cleanest pattern for a Next.js app is an Edge Route Handler that
proxies the Claude stream straight to the browser. The Edge runtime gives
you native ReadableStream plumbing and no cold-start tax.
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
export const runtime = "edge";
const client = new Anthropic({
apiKey: process.env.CODEXKEY_API_KEY!,
baseURL: "https://api.codexkey.online/v1",
});
export async function POST(req: Request) {
const { messages } = await req.json();
const controller = new AbortController();
req.signal.addEventListener("abort", () => controller.abort());
const stream = await client.messages.stream(
{
model: "claude-sonnet-5",
max_tokens: 1024,
messages,
},
{ signal: controller.signal },
);
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(ctrl) {
try {
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
ctrl.enqueue(encoder.encode(event.delta.text));
}
}
} catch (err) {
ctrl.enqueue(encoder.encode(`\n[error] ${(err as Error).message}`));
} finally {
ctrl.close();
}
},
cancel() {
controller.abort();
},
});
return new Response(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
Three details that catch teams out in production:
X-Accel-Buffering: noturns off nginx response buffering. Without it the stream is collected into a single chunk, and TTFT collapses back to the non-streaming case.Cache-Control: no-transformstops intermediaries from gzipping the response and re-chunking it.- Wiring
req.signalinto anAbortControlleris what lets you cancel the upstream Claude call when the browser tab closes. Without it you keep paying for tokens nobody will read.
Python: httpx async streaming
For backend-only services, the official anthropic Python SDK already
streams. When you need lower-level control (a custom proxy, a broadcast
fan-out, instrumentation), drop down to httpx.
import json
import httpx
URL = "https://api.codexkey.online/v1/messages"
async def stream_claude(prompt: str, api_key: str):
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"accept": "text/event-stream",
}
payload = {
"model": "claude-sonnet-5",
"max_tokens": 1024,
"stream": True,
"messages": [{"role": "user", "content": prompt}],
}
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", URL, headers=headers, json=payload) as resp:
resp.raise_for_status()
event_name = None
async for line in resp.aiter_lines():
if not line:
event_name = None
continue
if line.startswith("event: "):
event_name = line[7:].strip()
elif line.startswith("data: "):
data = json.loads(line[6:])
if event_name == "content_block_delta":
delta = data.get("delta", {})
if delta.get("type") == "text_delta":
yield delta["text"]
elif event_name == "error":
raise RuntimeError(data.get("error", {}).get("message", "stream error"))
elif event_name == "message_stop":
return
What to note:
timeout=Noneon the client is mandatory. The default 5-second read timeout will kill any stream longer than five seconds.aiter_lines()takes care of\r\nversus\nfor you.- The blank-line check resets
event_name, and that is deliberate. SSE records are separated by an empty line, and the next record may omit theevent:field (the type then defaults tomessage).
Backpressure and cancellation
Claude can produce tokens faster than your downstream consumer can write them, whether that consumer is a browser, a database or another service. If you ignore backpressure, the runtime queues bytes in memory and you hit OOM at scale.
In Node, a ReadableStream with the default byteLength queuing strategy
gives you backpressure for free. In Python, prefer async for to
collecting results into a list. For broadcast fan-out (one Claude stream
feeding many WebSocket clients), give each client a bounded asyncio.Queue
and drop the slow client, never the upstream.
Cancellation is the other half of the job. When the user closes the tab, abort the upstream request:
- TypeScript: forward
request.signalinto the SDK'ssignaloption. - Python: wrap the body loop in
try/finallyand let the httpx context manager close the connection. Or callawait resp.aclose()explicitly when the downstream client disconnects.
You pay for every token the model generates, including the ones nobody reads. That makes cancellation a cost-control feature as much as a UX nicety.
Common bugs to avoid
- Forgotten flush. Express, Fastify and any custom Node framework
buffer writes by default. Set
Content-Type: text/event-stream,Cache-Control: no-cacheandX-Accel-Buffering: no, then callres.flushHeaders()right away. - No pings, so the proxy cuts you off at 60 seconds. Cloudflare, nginx
and most ALBs close idle HTTP connections after 60 seconds. Claude's
pingevents keep the connection warm. If you filter them out and the model thinks for a while before producing text, the proxy drops the connection. Either pass pings through or emit your own keep-alive comments (: keepalive\n\n) every 15 seconds. - Tool-use JSON stitched together wrongly.
input_json_deltachunks are partial JSON fragments. Accumulate the string and callJSON.parseonly oncecontent_block_stopfires for that block. - A mid-stream error mistaken for a transport error. An
errorevent is a normal SSE record, not an HTTP failure. Your reader will not throw on its own, so check the event type and raise the error yourself. - Deltas logged synchronously.
console.logon every text_delta tanks throughput. Buffer the logs and write them onmessage_stop.
Bottom line
Of everything you can change in a Claude integration, streaming buys the
most UX per hour of work. Use the SDK where it fits, drop to raw SSE when
you must, and keep three things in mind: handle pings, abort on
disconnect, and never trust the default proxy timeouts. Point your
base_url at https://api.codexkey.online/v1, keep your existing
Anthropic SDK code, and ship.