How it works
The explanation, with examples you can run.
What arrives
A stream is a sequence of typed events. Names differ by provider, but the shape is similar:
- a start event with ids and metadata,
- many small deltas: pieces of text, and for tool calls, fragments of the arguments' JSON,
- a final event with the stop reason and token usage,
- occasionally an error event, and sometimes keep-alive pings.
Official SDKs assemble these for you and can hand you either each delta or the finished message. If you parse events by hand, accumulate tool-call argument fragments and only parse them once that block is complete.
The fake stream below emits deltas on a timer, the way a real one does, and shows the two numbers that matter for perceived speed.
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
Relaying a stream through your backend
Keep the provider key on your server. The browser calls your endpoint; your server authenticates the user, checks limits, calls the model with streaming on, and forwards text to the browser as it arrives. Propagate cancellation in both directions: if the browser disconnects, abort the upstream request.
// Placeholder client; a server route that relays model text as server-sent events.export async function POST(req: Request) { const user = await requireUser(req) const { messages } = await req.json() const upstream = await llm.stream({ model: MODEL, messages, signal: req.signal }) const body = new ReadableStream({ async start(controller) { const send = (data: unknown) => controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)) try { for await (const ev of upstream) { if (ev.type === "text_delta") send({ text: ev.text }) } send({ done: true }) } catch (err) { send({ error: "interrupted" }) // the UI marks the answer as incomplete log.error({ user: user.id, err }) } finally { controller.close() } }, }) return new Response(body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })}Things streaming makes harder
- Validation. You cannot validate an answer you have not finished receiving. Stream to the screen if you like, but run output checks (schemas, content filters, link checks) before saving, acting or sending anything further.
- Rendering. Markdown arrives half-formed (an open code fence, half a link). Use a renderer that tolerates partial input, and sanitise as you would any untrusted HTML.
- Errors after success. HTTP status is sent before the body, so a 200 can still end in an error event or a dropped connection. Show the answer as interrupted and offer a retry.
- Infrastructure. Proxies and serverless platforms may buffer responses or close idle connections; check timeouts, disable buffering for the route, and send occasional keep-alives on long answers.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.