Streaming responses
Perceived latency, not real latency.
Normally you wait for the model to finish writing and receive the whole reply at once. Streaming sends each piece as it is produced, so text appears a few words at a time. It is the reason a chat interface starts answering almost immediately instead of showing a spinner for twenty seconds.
The answer takes exactly as long either way and costs exactly the same. What changes is what the person waiting experiences, and that difference decides whether a product feels quick or broken. The price is paid on your side: you cannot check a reply against a schema until the last piece arrives, and a failure halfway through leaves you holding half an answer the user has already read.
It sits on the messages API and on asynchronous programming, which is how you hold the connection open while pieces arrive. It is the second half of the waiting problem: prompt caching cuts the time actually spent, streaming hides the time that remains. It complicates structured output and tool calling, both of which want a complete message before they can act.
Streaming changes nothing about how long the answer takes or what it costs — it changes when the reader sees the first word.
In more detailStreaming delivers tokens as they are generated, so the user sees output immediately. It changes perceived latency substantially and total cost not at all. It complicates everything downstream: you cannot validate a schema until the stream completes, and error handling mid-stream is genuinely awkward.
Learn first
Streaming a response
By the end: You can stream output to a user and handle the complications it introduces.
- Why streaming exists
- Consuming a stream
- What it breaks
- Errors that arrive mid-stream
A model generates one token at a time, so the first word exists long before the last one. Streaming sends each piece as it is produced rather than waiting for the whole response, which changes a twenty-second wait into a response that starts in half a second. Nothing about the total time improves — the change is entirely in what the user experiences — and it costs you a harder failure model, because by the time something goes wrong you have already sent a success. This section covers consuming a stream correctly, what it breaks, and how errors arrive once you have committed to a 200.
Why streaming exists#
The measurement that matters is time to first token, not total time. A response of five hundred tokens at fifty tokens a second takes ten seconds either way; unstreamed, the user waits ten seconds and then reads, and streamed, they start reading after perhaps four hundred milliseconds and the rest arrives faster than they can read it. Perceived latency collapses even though real latency is unchanged.
That is also the argument for streaming even when you do not display the tokens. A stream gives you a liveness signal: an unstreamed request that is hung and one that is working look identical until the timeout, whereas a stream that has stopped producing deltas is visibly stuck, and you can fail over while the user is still watching something happen.
Consuming a stream#
Providers use server-sent events, which is a text format over ordinary HTTP: lines of field-and-value, with a blank line ending each event. It is simple enough to parse by hand, and worth doing once because the thing that breaks is not the format but the chunking.
def parse_sse(chunks):
"""Lines of field: value, a blank line ends an event. The buffer is the
whole trick -- an event can be split across any number of chunks."""
buffer, events = "", []
for chunk in chunks:
buffer += chunk
while "\n\n" in buffer:
raw, buffer = buffer.split("\n\n", 1)
event = {}
for line in raw.splitlines():
if not line or line.startswith(":"):
continue # blank or comment
field, _, value = line.partition(":")
event[field.strip()] = value.strip()
if event:
events.append(event)
return events, buffer
# A provider does not send one event per chunk. This is the realistic case:
chunks = [
'event: delta\ndata: {"text": "Hel',
'lo"}\n\nevent: delta\nda',
'ta: {"text": " world"}\n\nevent: done\ndata: [DONE]\n\n',
]
events, leftover = parse_sse(chunks)
for e in events:
print(e)
naive = [c for c in chunks if c.startswith("event:")]
print("naive parser found", len(naive), "events instead of", len(events))
# {'event': 'delta', 'data': '{"text": "Hello"}'}
# {'event': 'delta', 'data': '{"text": " world"}'}
# {'event': 'done', 'data': '[DONE]'}
# naive parser found 1 events instead of 3In practice the provider SDK does this for you and you iterate over typed events. Write it once anyway: the day a stream misbehaves, knowing that the transport is line-delimited text with a buffering rule is the difference between reading the raw bytes and guessing.
What it breaks#
Streaming removes the moment where you had the whole response and could decide what to do with it. Several things you were relying on lived in that moment.
what you lose why what to do instead
validating the output it arrives in pieces validate at the end, or buffer
a moderation pass nothing to scan yet stream to a buffer, then release
retrying cleanly the user saw half of it only retry before the first token
exact token counts usage arrives last read the final usage event
structured output partial JSON is invalid parse incrementally, or buffer
caching the response there is no response yet assemble, then cacheStructured output and streaming rarely mix
A half-finished JSON object is not JSON, so anything parsing the model's output as a schema has to wait for the end anyway. At that point streaming has bought nothing except a harder error path, and the honest answer is to turn it off for those calls. The exception is when you are showing progress from a long structured generation, and then you want an incremental parser that tolerates truncation rather than json.loads on every delta.
Errors that arrive mid-stream#
This is the part that catches people. An unstreamed call either succeeds or fails, and you find out before responding. A streamed call sends 200 and a set of headers immediately, then fails after eight hundred tokens — a rate limit reached mid-generation, an overloaded backend, a dropped connection. The status code has already been sent and cannot be taken back.
- Errors arrive as an event in the stream, not as an HTTP status. Handle the error event type explicitly; a parser that only knows about deltas will treat it as an unfinished response.
- A stream that simply stops is indistinguishable from one that finished, unless you require the terminal event. Treat a stream that ends without it as a failure.
- Retrying after partial output means the user sees the beginning twice. Either retry only before the first token has been sent, or replace the whole message in the interface rather than appending.
- Your own server must forward the failure to the client somehow. The usual answer is an error event in your own stream plus a visible state in the interface, because you cannot change a 200 into a 500.
- Set an inactivity timeout, not just a total one. A stream producing nothing for thirty seconds is dead, and a total timeout of ten minutes will not notice for nine of them.
The inactivity timeout is the one most often missing, and it is also the one that turns a provider incident into a pile of stuck connections holding workers open. Time the gap between deltas rather than the whole request, and treat an exceeded gap as a failed stream you can fall back from — which is possible precisely because you are still connected and still producing something the user can see.
Where people go wrong
Streaming to the user and parsing at the same time. The partial JSON is invalid at every intermediate point, and code that tries to parse it produces a stream of caught exceptions.
Build this: Build a streaming chat endpoint, then deliberately kill the connection halfway through and decide what the user should be left looking at.
Open this section on its own pageStreaming from the model to the screen
By the end: You can build a streaming feature end to end, including cancellation, proxies and tool calls.
- Server-sent events, and why APIs use them
- Forwarding a stream through your server
- Proxies that buffer
- Cancelling, and what it costs
- Streaming tool calls
- Measuring time to first token
Streaming does not make a response faster. It makes the wait tolerable, which to a user amounts to much the same thing. This section builds it properly end to end, because the naive version breaks in specific and predictable places. The browser must not hold your API key, so the stream passes through your server. Some proxies buffer the whole response and deliver it in one piece, so it works locally and arrives all at once in production. Cancelling has to actually stop the upstream call or you pay for tokens nobody reads, and tool calls arrive in fragments that need reassembling.
The shape of it#
model API --stream--> your server --stream--> browser
(auth, logging, (renders as
limits, keys) text arrives)The browser should never hold your API key, so the stream passes through your server. Major model APIs stream using server-sent events: a long-lived HTTP response that sends small text events as they are ready. It is plain HTTP, so it works through most infrastructure, with one catch below.
Forwarding a stream#
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
class ChatRequest(BaseModel):
messages: list
async def model_stream(messages): # your SDK call, stubbed so this runs
for text in ["Hel", "lo", " world"]:
yield text
def sse(payload):
"""One event: a data line, then a blank line. The blank line is the frame."""
return f"data: {json.dumps(payload)}\n\n"
@app.post("/chat")
async def chat(req: ChatRequest):
async def events():
async for text in model_stream(req.messages):
yield sse({"text": text})
yield "data: [DONE]\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
print(repr(sse({"text": "Hel"})))
# 'data: {"text": "Hel"}\n\n'Two details in that handler are the whole job. The events are generated lazily, so nothing is assembled before it is sent — a handler that builds a list and returns it has a streaming content type and no streaming. And the media type is text/event-stream, which is what tells every layer between you and the browser that this response is not a document. Parsing the events on the way in is covered in the first section of this topic, which writes an SSE parser and shows what happens when an event is split across two network reads; this section assumes that parser and deals with the pipe it runs through.
The proxy that ruins it#
Some proxies and load balancers buffer responses and send them in one piece when complete. Your stream works locally and arrives all at once in production. Check each hop. For nginx, response buffering has to be turned off for this route, and a header such as X-Accel-Buffering: no does that per response.
Finding which hop is doing it is a measurement rather than an argument. Call the application directly, then through each layer in turn, with a client that timestamps every read rather than waiting for the whole body. Two numbers separate the cases: how many reads the client made, and how long after the request the first one arrived. A stream produces dozens of reads spread across the response; a buffered stream produces one read at the very end, holding exactly the same bytes.
def record(prefill, rate, n, buffered=False):
"""(seconds since the request was sent, deltas in that read). Recorded by a
client that timestamps every read; buffered=True is what a proxy gives you."""
end = prefill + n / rate
if buffered:
return [(round(end, 3), n)]
return [(round(prefill + i / rate, 3), 1) for i in range(n)]
def stats(trace):
ttft, last = trace[0][0], trace[-1][0]
deltas = sum(n for _, n in trace)
span = last - ttft
return ttft, last, deltas, (deltas / span if span > 0 else None), len(trace)
print(f"{'hop':<12}{'ttft':>8}{'total':>8}{'deltas':>8}{'reads':>7}{'tok/s':>8} verdict")
for name, trace in [("direct", record(0.42, 25, 40)),
("via proxy", record(0.42, 25, 40, buffered=True))]:
ttft, last, deltas, tps, reads = stats(trace)
verdict = "streaming" if reads > 1 else "buffered, one read at the end"
rate = f"{tps:.1f}" if tps else "-"
print(f"{name:<12}{ttft:>7.2f}s{last:>7.2f}s{deltas:>8}{reads:>7}{rate:>8} {verdict}")
# hop ttft total deltas reads tok/s verdict
# direct 0.42s 1.98s 40 40 25.6 streaming
# via proxy 2.02s 2.02s 40 1 - buffered, one read at the endThe usual culprits, in the order they are worth checking: nginx or another reverse proxy with response buffering on by default; compression, because gzip wants a block of bytes to compress and will hold them until it has one; a CDN in front of the origin treating the response as a cacheable document; and a serverless platform whose function response model returns a whole body rather than a stream. Each is fixed in a different place, which is why the measurement above is worth more than a list of settings.
What streaming actually buys#
print(f"{'reply tokens':>13}{'unstreamed':>12}{'streamed':>10}{'saved':>8}")
for n in (40, 200, 800):
total = 0.42 + n / 25
print(f"{n:>13}{total:>11.1f}s{0.42:>9.2f}s{total - 0.42:>7.1f}s")
# reply tokens unstreamed streamed saved
# 40 2.0s 0.42s 1.6s
# 200 8.4s 0.42s 8.0s
# 800 32.4s 0.42s 32.0sTime to first token is dominated by prefill, which scales with how much prompt the model has to read, so a long retrieved context pushes the one number streaming was supposed to protect. Watch it as a percentile rather than a mean — the median user sees the healthy number and the tail is where the abandoned sessions are — and watch total time separately, because it is unchanged by streaming and it is the only figure that matters to anything automated downstream.
Cancelling#
Users press stop, close the tab, or send a new message. When the browser disconnects, your server should stop reading from the model and close that connection too, otherwise the model keeps generating text nobody will see. You are generally billed for tokens generated up to the point the request is cancelled, so stopping promptly saves money.
The failure is quiet, because a server that ignores the disconnect and drains the upstream stream into nothing behaves correctly in every way a test would check. Here is what it costs, with a model that produces four hundred tokens and a reader who leaves after thirty.
import asyncio
async def upstream(queue, n_tokens, meter):
"""Stands in for the model. Every token it produces is billed."""
for i in range(n_tokens):
meter["generated"] += 1
await queue.put(f"t{i} ")
await queue.put(None)
async def forward(read_n, propagate):
meter = {"generated": 0}
queue = asyncio.Queue(maxsize=1) # the model is paced by the reader
task = asyncio.create_task(upstream(queue, 400, meter))
for _ in range(read_n): # the browser then disconnects
await queue.get()
if propagate:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
while await queue.get() is not None: # your server drains it anyway
pass
await task
return meter["generated"]
OUT_PER_M = 15.00 # usd per million output tokens
for propagate in (True, False):
billed = asyncio.run(forward(30, propagate))
label = "cancel propagated" if propagate else "client gone, upstream not told"
wasted = billed - 30
print(f"{label:30}{billed:>6} billed{wasted:>6} wasted"
f"{wasted * OUT_PER_M:>10.0f} usd per million such requests")
# cancel propagated 31 billed 1 wasted 15 usd per million such requests
# client gone, upstream not told 400 billed 370 wasted 5550 usd per million such requestsIn a real handler the disconnect arrives as an event from the framework — Starlette calls it await request.is_disconnected, and in Node it is the request close event — and the response is to cancel the task holding the upstream connection, not merely to stop writing to the client. The SDK streaming context manager does the right thing on exit, so the pattern that works is to keep the upstream call inside a task you own and can cancel, rather than inside a generator someone else is iterating.
Tool calls arrive in pieces#
When the model calls a tool during a stream, the arguments arrive as fragments of JSON. You cannot parse them until the fragment stream for that call has finished. Accumulate, then parse, then run the tool.
import json
DELTAS = [
{"index": 0, "name": "search_orders", "args": ""},
{"index": 0, "args": '{"customer'},
{"index": 0, "args": '_id": "c-8'},
{"index": 0, "args": '81", "status"'},
{"index": 0, "args": ': "refunded"}'},
{"index": 0, "stop": True},
]
def accumulate(deltas):
calls, naive_failures = {}, 0
for d in deltas:
call = calls.setdefault(d["index"], {"name": None, "args": ""})
if d.get("name"):
call["name"] = d["name"]
call["args"] += d.get("args", "")
if d.get("args"):
try:
json.loads(call["args"]) # what parsing early looks like
except json.JSONDecodeError:
naive_failures += 1
if d.get("stop"):
call["parsed"] = json.loads(call["args"])
return calls, naive_failures
calls, failures = accumulate(DELTAS)
print(calls[0]["name"], calls[0]["parsed"])
print(f"{failures} of {sum(1 for d in DELTAS if d.get('args'))} fragments were not valid JSON on their own")
# search_orders {'customer_id': 'c-881', 'status': 'refunded'}
# 3 of 4 fragments were not valid JSON on their ownThe interface question that follows is what to show while that is happening. Showing the fragments is meaningless, and showing nothing loses the liveness streaming bought you, so the usual answer is to render the tool name as soon as it arrives — 'searching orders' — and the result when the tool returns. That also gives you somewhere honest to put a tool call that fails.
Measure what users feel#
- Time to first token: how long until anything appears. This is what streaming improves.
- Tokens per second after that: how fast text flows.
- Total time: unchanged by streaming, and what matters for anything automated.
- Cancellation rate, and how far into the reply people leave. It sizes the waste above and tells you whether your answers are too long.
- Streams that ended without a terminal event, as a rate. A stream that stops looks exactly like one that finished unless you require the last event and count the ones that never sent it.
Compression is the other buffer
Turning off proxy buffering and still seeing the whole answer at once usually means gzip. A compressor wants a block of bytes before it emits anything, so a layer compressing the response will hold your deltas whether or not it calls itself a buffer. Disable compression on the event-stream route, or use a mode that flushes per write. It is worth checking second, right after proxy_buffering, because the symptom is identical and the setting lives somewhere else entirely.
Where people go wrong
Testing streaming only on localhost. Buffering, timeouts and dropped connections live in the infrastructure between you and the user.
Build this: Build a small server that forwards a model stream to a browser page, handles stop by cancelling upstream, and logs time to first token and total time per request. Deploy it behind a proxy and confirm it still streams.
Open this section on its own pageNow actually do it
Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.
1Stream, then break the stream
5hBuild a streaming endpoint. Kill the connection halfway and decide what the user is left looking at. Then try to validate a schema mid-stream and see why it cannot work.
Done when: A mid-stream failure leaves the user with partial text and a clear notice rather than nothing.
5 hours of practice
Interview questions
7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.
Streaming a response
Does streaming reduce latency?
It reduces perceived latency by showing tokens as they are generated. Total time to the complete answer and total cost are unchanged.
What does streaming complicate?
Validation, since you cannot check a schema until the stream ends. Error handling, since a failure can arrive after the user has seen half an answer. Retries and output moderation, both of which assume you can act before anything is shown.
Would you stream a response that must be valid JSON?
There is little point. You cannot use it until it is complete, so streaming only gives you a progress indicator. Stream prose to users, and buffer anything you intend to parse.
Then they push further
What if the user needs to see progress?
Stream status updates or a separate readable field while the structured result is produced, and parse the JSON only once it is complete. Stream partial JSON with a tolerant parser only if the interface truly needs it.
What gets marked up. Marked up for noting that JSON cannot be validated until it is complete, so streaming helps the person watching but not the program consuming the result.
Streaming from the model to the screen
Your streaming chat works locally but the whole answer appears at once in production. Why?
Something between the server and the browser is buffering the response, usually a reverse proxy or load balancer. Turn off buffering for that route, check compression settings, and test each hop.
Then they push further
How would you find which hop is buffering?
Call each layer directly in turn, the application, then through the proxy, then through the load balancer, with a client that prints events with timestamps. The first layer where events arrive together is the culprit.
What gets marked up. Marked up for going straight to buffering in intermediaries and having a method to find it.
What happens when a user cancels a streamed answer?
The server should notice the client disconnect and cancel the upstream request, so generation stops. Otherwise the model keeps producing tokens you pay for and nobody reads.
What metric does streaming improve?
Time to first token, which is what users perceive as speed. Total generation time is unchanged.
How do you handle a tool call that arrives in a stream?
The arguments come as fragments of a JSON string, split at points that respect nothing about JSON, so you accumulate them keyed by the call index and parse once the block is finished. Parsing each fragment as it arrives fails on nearly all of them. Show the tool name in the interface as soon as it arrives, and the result when the tool returns, so the user sees progress without seeing half-formed JSON.
What gets marked up. Marked up for keying by index, which is what makes parallel tool calls work, and for knowing that the name arrives before the arguments.
Resources
Free to watch and read, picked for this topic.
For streaming a response
For streaming from the model to the screen
Full courses: the whole topic, start to finish
Stop 12 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.