The Six-Second Spinner
The chat UI shows a spinner for 6.2 seconds and then drops a 400-token answer on screen in one lump. The model started writing at 800 ms; for 5.4 of those seconds the user watched nothing happen. Streaming does not make generation faster — it changes when the first token becomes visible.
That number has a name: time-to-first-token (TTFT), and it is what users perceive as "the system is thinking":
buffered: [---- spinner 6.2s ----][ text lands at once ]
^ request ^ user's TTFT = 6.2s
streaming: [spinner 0.8s][ Id][emp][ot][ency][ means][ ...]
^ TTFT = 0.8s ^ tokens keep arriving while the user reads
To see what actually arrives, watch the raw connection. "stream": true keeps the HTTP response open and turns it into text/event-stream: a sequence of data: frames, each holding one small JSON piece, separated by newlines:
POST /v1/chat/completions
{"model": "gpt-4o-mini", "stream": true, "messages": [...]}
<-- HTTP/1.1 200 OK
<-- content-type: text/event-stream
data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"choices":[{"delta":{"content":"Idem"},"index":0}]}
data: {"choices":[{"delta":{"content":"pot"},"index":0}]}
data: {"choices":[{"delta":{"content":"ency"},"index":0}]}
...
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"index":0}]}
data: [DONE]
The same request through curl, which needs no SDK to make the point:
curl -N https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,
"messages":[{"role":"user","content":"Define idempotency in one sentence."}]}'
Input: Define idempotency in one sentence.
Output (first frames): {"delta":{"role":"assistant"}...} → {"delta":{"content":"Idem"}} → {"delta":{"content":"pot"}}
Frame one carries the role and no text. Frame two starts the answer. The final frame carries finish_reason with an empty delta. Those three shapes are the whole protocol, and they are why a parser written against the buffered response crashes on frame one. Latency Profiling measures TTFT against your own budget; OpenAI SDK Essentials covers what streaming does to usage accounting.
Consuming Deltas and Reassembling the Answer
No frame contains a sentence — each is a fragment, and the answer exists only after every fragment is concatenated in order. The client's job is a short loop with two guards that exist for specific crash reasons:
from openai import OpenAI
client = OpenAI()
stream = client.chat.completions.create(
model="gpt-4o-mini",
stream=True,
messages=[{"role": "user", "content": "Explain idempotency in one sentence."}],
)
parts = []
for chunk in stream:
choice = chunk.choices[0] if chunk.choices else None # terminal chunk: no choices
if choice is None:
continue
parts.append(choice.delta.content or "") # role-only frame: content None
if choice.finish_reason:
last_reason = choice.finish_reason
answer = "".join(parts)
print(last_reason, answer)
Input: Explain idempotency in one sentence.
Output:
stop Idempotency means retrying a request produces the same server-side effect as sending it once.
Why each guard exists:
choice.delta.content or ""— the first frame setsdelta.roleand leavescontentasNone. AppendingNoneraisesTypeErroron frame one; this is the most common first bug in streaming code.if chunk.choices else None— withstream_options={"include_usage": True}the final chunk arrives with an emptychoicesarray and theusageblock attached. Indexing[0]there raisesIndexError.finish_reasonarrives on a frame after the text ends. Read it instead of inferring completion from silence:stopmeans finished,lengthmeans the token cap cut the answer.
Anthropic's helper hides the frame bookkeeping behind an iterator of assembled fragments:
anthropic_client = Anthropic() # key from $ANTHROPIC_API_KEY
with anthropic_client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=512,
messages=[{"role": "user", "content": "Explain idempotency in one sentence."}],
) as stream:
for text in stream.text_stream: # fragments, concatenated per content block
render(text)
Output: the same sentence arriving as Idem → pot → ency fragments.
One invariant deserves an automated test: "".join(parts) must equal the non-streaming response for the same prompt and settings. When they diverge, the bug is in your guards — a dropped None, a frame counted twice after a reconnect — not in the model. The test scaffolding for exactly this kind of assertion is Testing Python AI Code.
When the Stream Stops Early
A proxy kills the idle connection at 3.2 seconds and the user is holding half a sentence. Streaming trades one failure mode for another: instead of a slow blank wait, you now own a partially delivered response — and the recovery policy depends entirely on when the failure happened.
[stream opens]
|
+----------------+----------------+
| 401 / 400 / 429 / connect fail | first delta seen
| raises before any byte exists |
v v
retry is SAFE: [mid-stream failure]
nothing was rendered keep buffer if partial text exists
mark truncated, continue in a
follow-up turn — never replay
Failures at open. Auth, payload, and rate-limit errors raise while the request is being established: AuthenticationError (401 — fix the key, never retry), BadRequestError (400 — your payload), RateLimitError (429 — back off), plus connect failures. The handler wraps the stream open, not the loop:
import httpx
from openai import OpenAI, APIConnectionError, APITimeoutError
client = OpenAI(
timeout=httpx.Timeout(connect=5.0, read=60.0), # read budget spans silence
max_retries=0, # recovery is yours on a stream
)
buf = []
try:
for chunk in client.chat.completions.create(
model="gpt-4o-mini", stream=True, messages=messages
):
if chunk.choices:
buf.append(chunk.choices[0].delta.content or "")
except (APIConnectionError, APITimeoutError):
partial = "".join(buf)
if not partial: # nothing rendered: full retry is safe
retry_later(messages)
else: # text already shown: never replay it
record_truncated(partial) # then continue in a follow-up turn
Input: a 400-token answer interrupted after Idempotency means retrying a.
Output: record_truncated stores the partial text, the UI marks the answer incomplete, and retry_later is skipped — re-emitting from the start would double text the user already read.
Two configuration details carry weight here. The read timeout spans the silence between frames, not the whole response, so a generous read budget with a tight connect budget is the correct asymmetry for streaming. And max_retries=0 disables the SDK's automatic replay: that helper re-sends the full prompt, and on a stream that died halfway, a replay is a duplicate answer, not a recovery.
Tool calls stream too. Argument payloads arrive as fragments — delta.tool_calls[i].function.arguments on OpenAI, input_json_delta.partial_json on Anthropic — and long JSON arguments are where the saved round trip pays off most: the fragments are already in memory while the model is still writing, ready to validate or dispatch the moment the stream signals completion.
args = ""
for chunk in stream:
choice = chunk.choices[0]
if choice.delta.tool_calls:
args += choice.delta.tool_calls[0].function.arguments or ""
if choice.finish_reason == "tool_calls":
tool_call = json.loads(args) # fragments form a complete object
break
Input: a tool call streaming {"city": "Seattle", "unit": "celsius"} in fragments.
Output: the parsed dict the moment the closing brace arrives.
Parse only on the signal — finish_reason on OpenAI, stop_reason: tool_use on Anthropic. JSON is invalid until its last byte, so premature json.loads fails by design; what streaming buys is that the arguments are assembled and ready before the rest of the answer finishes. Backoff policy belongs to Rate Limiting and Retries, and the Anthropic event taxonomy is in Anthropic SDK Essentials.
Quiz
1. A stream's first chunk carries delta with role assistant and no content. What should the consumer do?
2. A proxy drops the connection after half the answer has been rendered to the user. What is the correct recovery?
3. Where does a 429 surface when streaming, and where must the handler sit?
4. Why stream tool-call arguments instead of waiting for the complete answer?
Flashcards
Question
What does streaming change, and what does it leave alone?
Click to reveal answer
Answer
It collapses time-to-first-token from the full generation time to the first frame — total generation time and cost are unchanged; the user just starts reading sooner.
Question
What are the three shapes of an SSE delta stream?
Click to reveal answer
Answer
Opening frame: role only, content None. Body frames: content fragments in order. Closing frame: empty delta with finish_reason (or usage on an empty choices array when requested).
Question
How is the full answer recovered from a stream?
Click to reveal answer
Answer
Concatenate every delta.content in arrival order, guarding None on the role frame and skipping the terminal usage chunk. The join must equal the non-streaming response for identical inputs.
Question
What is the recovery rule for an interrupted stream?
Click to reveal answer
Answer
Before the first delta: retry the whole request (nothing rendered). After partial text: keep the buffer, mark truncated, continue in a follow-up turn — replaying would duplicate rendered text.
Question
How are streamed tool-call arguments handled?
Click to reveal answer
Answer
Accumulate the argument fragments (delta.tool_calls[].function.arguments / partial_json) and parse only when the stream signals completion — finish_reason tool_calls or stop_reason tool_use.
Revision Notes
Key Takeaways
- 1. Streaming cuts perceived latency without touching generation speed — TTFT is the metric users feel
- 2. Deltas are fragments: role frame first, content pieces in order, finish_reason last; the answer is the concatenation
- 3. Guard the two crash frames — content None on the role chunk, empty choices on the usage chunk
- 4. Recovery is decided by timing: retry before the first delta, preserve and continue after partial text
- 5. Stream tool-call arguments as fragments and parse only when the stream signals completion
Interview Tips
- • Be ready to explain TTFT versus total latency — an interviewer asking 'why does streaming feel faster' wants the distinction, not 'it uses SSE'
- • Walk the crash cases on a whiteboard: None on frame one, IndexError on the usage chunk, TypeError when concatenating without guards
- • Have the interruption policy ready: what you do before the first delta versus after partial text, and why replaying is wrong
- • If asked about tools, describe fragment accumulation and why you parse only on finish_reason — premature json.loads fails by design
Cheat Sheet
Streaming Responses — Cheat Sheet
The latency story
buffered: [ spinner 6.2s ][ all text at once ] user TTFT = 6.2s
streaming: [ spinner 0.8s ][ tokens keep arriving ] TTFT = 0.8s
Total generation time is unchanged — streaming only moves the first visible token.
Wire format (SSE)
data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"choices":[{"delta":{"content":"Idem"},"index":0}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"index":0}]}
data: [DONE]
Reassembly loop
parts = []
for chunk in stream:
choice = chunk.choices[0] if chunk.choices else None
if choice is None:
continue
parts.append(choice.delta.content or "") # None on the role frame
if choice.finish_reason:
last_reason = choice.finish_reason
answer = "".join(parts)
- Anthropic:
with client.messages.stream(...) as s: for text in s.text_stream - Invariant:
"".join(parts)== non-streaming response for same inputs
Failure matrix
| When it fails | Evidence | Recovery |
|---|---|---|
| At open (401/400/429, connect) | Exception before any delta | 401 fix key · 400 fix payload · 429 back off · connect retry |
| Before first delta, transport only | buf empty | Retry the whole request |
| Mid-stream | buf has partial text | Keep buffer, mark truncated, continue in follow-up turn |
| Token cap mid-answer | finish_reason length | Raise max_tokens or continue in a follow-up turn |
Configuration
client = OpenAI(
timeout=httpx.Timeout(connect=5.0, read=60.0), # read spans inter-frame silence
max_retries=0, # never auto-replay a stream
)
Tool-call streaming
- OpenAI:
delta.tool_calls[i].function.argumentsfragments - Anthropic:
input_json_delta.partial_jsonfragments - Parse on signal:
finish_reason == "tool_calls"/stop_reason == "tool_use"
Continue Learning
Before You Start
- No prerequisites listed.