SSE Streaming Explained: From curl to Frontend
LLM streaming is just an SSE long HTTP connection: event-stream format, stream:true chunks, curl and frontend debugging, and classic proxy-buffering pitfalls.
Open any AI chat product and the answer "types itself out." That's not the model pushing words one by one through some magic channel — it's an old, established web technique: SSE (Server-Sent Events). LLM API streaming is essentially the server holding an HTTP response open and writing lines of text into it.
Understanding SSE isn't just curiosity — most of the bizarre problems in streaming debugging (a stuck response, a frontend that never receives data, a bill that doesn't add up) live somewhere along this chain. This post walks the whole path: wire format, curl testing, and frontend rendering.
What SSE is: one-way push over plain HTTP
SSE is not a new protocol. It's just an ordinary HTTP response that refuses to end. The server returns Content-Type: text/event-stream and keeps writing text into the body; the client reads as it arrives. How it differs from WebSocket explains why every LLM API chose it:
| Dimension | SSE | WebSocket | Polling |
|---|---|---|---|
| Direction | Server → client, one-way | Bidirectional | Client pulls |
| Protocol | Plain HTTP | Separate protocol (ws://) | Plain HTTP |
| Cost to build | Near zero — curl works | Dedicated server + heartbeats | High latency |
| Reconnect | Browser auto-reconnects | DIY | Stateless by nature |
| Best for | LLM streaming, notifications | Collaborative editing, games | Low-frequency data |
LLM traffic is "one request, one continuous output" — one-way is enough, so SSE is the cheapest correct choice.
The wire format: data: lines and [DONE]
With an OpenAI-compatible API, add "stream": true to the request and the body becomes a series of events separated by blank lines:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hel"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]Three fields matter most:
delta.content: the small increment of text each time (not cumulative — the frontend must concatenate);finish_reason:stopmeans a clean ending,lengthmeansmax_tokenscut it off — check here first when an answer "just stops";data: [DONE]: the end-of-stream sentinel. It is not JSON — guard before parsing.
One high-frequency trap: streamed responses carry no usage (token count) by default. For billing stats add "stream_options": {"include_usage": true} and the platform attaches usage to the final chunk. Missing this parameter is reason #1 behind "streaming works fine but the bill doesn't match."
Layer 1 of debugging: see the raw stream with curl
Always bypass your own code first and look at raw bytes:
curl -N https://api.zaimod.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4.1-flash",
"messages": [{"role": "user", "content": "Explain SSE in three sentences"}],
"stream": true,
"stream_options": {"include_usage": true}
}'-N (--no-buffer) is the soul of this command: it disables curl's output buffering so data appears the moment it arrives. Without it you'll see chunks appear in bursts and misdiagnose server lag.
At this layer you can verify: whether the server truly streams (rather than dumping everything at the end); whether every chunk parses as JSON; whether finish_reason and [DONE] show up properly. If curl is already broken, the problem is server- or network-side, not your code. If request parameters are unfamiliar, skim the OpenAI API docs first.
Layer 2 of debugging: your forwarding backend
In production the API usually passes through your backend before reaching the frontend, and streaming breaks here most often. Three classic problems:
- Proxy buffering. Nginx buffers upstream responses by default, turning your stream into one lump. Add
proxy_buffering off;(or send theX-Accel-Buffering: noheader); CDNs are the same — many don't passtext/event-streamthrough untouched. - Read timeouts too short. The model can "think" for over 30 seconds between chunks, and gateways kill the idle connection. Give streaming routes generous read timeouts, or send SSE comment lines (lines starting with
:) as keepalive heartbeats. - Error timing. Once HTTP 200 is already sent, you can't change the status code when the model fails mid-stream — emit an error-bearing JSON event inside the stream instead of letting the connection die silently.
The exclusion order: curl direct works → check whether chunks keep arriving in your backend logs → then inspect proxy config. A class of "frontend froze after switching base_url" issues (see what breaks when you change base_url) traces back to a buffering layer on the new path.
Layer 3 of debugging: frontend ReadableStream rendering
No library needed in the browser — fetch plus ReadableStream consumes SSE fine:
const resp = await fetch("https://api.zaimod.com/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "deepseek-v4.1-flash",
messages: [{ role: "user", content: "Explain SSE in three sentences" }],
stream: true,
}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Split on blank lines; keep the remainder in the buffer
const parts = buf.split("\n\n");
buf = parts.pop();
for (const part of parts) {
const line = part.replace(/^data: /, "");
if (line === "[DONE]") continue;
const chunk = JSON.parse(line);
const text = chunk.choices[0]?.delta?.content ?? "";
if (text) appendToBubble(text); // incremental rendering
}
}Two easy-to-miss details: network packets don't align with event boundaries, so buffer until you see the blank line before parsing — that's what parts.pop() handles; and pass { stream: true } to TextDecoder, otherwise a multi-byte Chinese character split across two packets renders as garbage. In React/Vue, append the increment into state; never reset the whole string each time.
Streaming and billing: three things to know
Back to money. Streaming doesn't change the total, but it changes how you count:
- Tokens are billed on the sum of all increments, identical to one non-streamed request — streaming saves perceived latency, not cost;
- For usage stats, either read the final chunk with
include_usage, or reconcile per-model in the console afterward (see the billing & error handbook when numbers disagree); max_tokensstill applies when streaming — hitting the cap yields finish_reasonlength, notstop; monitor the two endings separately.
A complete runnable minimal example (including streaming) is in the quickstart. Sign-up includes trial credit — run the curl command above and watch your first SSE stream within two minutes.