MODULE 6  ยท  Model Serving & APIs

Streaming: Why AI Responds One Word at a Time (And Why It Feels Fast)

Watch ChatGPT's words appear one by one, like someone typing in real time. It's not a UX trick โ€” it's how generation works.

๐Ÿ“… Mar 2026
โฑ 10 min read
๐ŸŽฏ Episode 37 of 98
StreamingSSEUXAPI Design
In this episode

Open ChatGPT. Ask it anything. Watch the words appear one by one, like someone typing in real time.

That's not a UI animation. It's not a deliberate design choice to make it "feel" more human. The model genuinely doesn't know word 2 until word 1 is generated. Each token is a fresh prediction, and streaming delivers each one the instant it's ready.

Without streaming, you'd stare at a blank screen for 5-15 seconds, then get the entire answer dumped at once. With streaming, you start reading within 200 milliseconds. Same total time, completely different experience.

Here's how it works.

Why Streaming Exists

LLMs generate tokens sequentially. A 500-token response takes about 5-10 seconds (depending on the model and provider). Without streaming:

User sends message โ†’ [5 seconds of nothing] โ†’ Entire response appears

With streaming:

snippet
code
User sends message โ†’ [200ms] โ†’ "The" โ†’ "capital" โ†’ "of" โ†’ "France" โ†’ "is" โ†’ "Paris."

The total time is the same. But the time to first token (TTFT) drops from 5 seconds to ~200ms. The user starts reading immediately, and the response feels instant.

Metric Without Streaming With Streaming

Time to first token5-15 seconds200-500ms
Total time5-15 seconds5-15 seconds
Perceived speedSlow, frustratingFast, responsive
User can read while generatingNoYes
Can be interruptedNo (already generated fully)Yes (stop mid-stream)
โšก

Server-Sent Events (SSE)

Streaming uses Server-Sent Events (SSE) โ€” a simple HTTP protocol for server-to-client streaming. It's just regular HTTP with a special content type:

HTTP/1.1 200 OK

Content-Type: text/event-stream

Cache-Control: no-cache

Connection: keep-alive

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"The"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" capital"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" of"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" France"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" is"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" Paris"}}]}

data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"."}}]}

data: {"id":"chatcmpl-123","choices":[{"finish_reason":"stop"}]}

data: [DONE]

Each line starting with data: is a chunk. Each chunk contains a delta โ€” the new token(s) being added. Your client reads these chunks as they arrive and appends them to the display.

Key Differences from Regular HTTP

Regular HTTP SSE (Streaming)

Server sends complete response, then closesServer keeps connection open, sends chunks
Content-Type: application/jsonContent-Type: text/event-stream
Client gets everything at onceClient processes chunks as they arrive
One response per requestMany data events per request
Connection closes after responseConnection stays open until [DONE]

๐Ÿ”ง

Implementing Streaming

Python (OpenAI SDK)

python

from openai import OpenAI

snippet
code
client = OpenAI()

Non-streaming (wait for full response)

snippet
code
response = client.chat.completions.create(
model="gpt-4o",
    messages=[{"role": "user", "content": "Write a haiku about coding"}],
    stream=False  # Default

)

snippet
code
print(response.choices[0].message.content)
Streaming (get tokens as they generate)
snippet
code
stream = client.chat.completions.create(
model="gpt-4o",
    messages=[{"role": "user", "content": "Write a haiku about coding"}],
    stream=True

)

snippet
code
full_response = ""

for chunk in stream:

example
code
if chunk.choices[0].delta.content:
        token = chunk.choices[0].delta.content
        print(token, end="", flush=True)  # Print without newline
        full_response += token
snippet
code
print()  # Final newline

JavaScript/TypeScript (Frontend)

javascript

async function streamChat(message) {

example
code
const response = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: {
            'Authorization': `Bearer ${API_KEY}`,
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            model: 'gpt-4o',
            messages: [{ role: 'user', content: message }],
            stream: true,
        }),
    });
example
code
const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
example
code
while (true) {
        const { done, value } = await reader.read();
        if (done) break;
example
code
buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // Keep incomplete line in buffer
example
code
for (const line of lines) {
            if (line.startsWith('data: ')) {
                const data = line.slice(6);
                if (data === '[DONE]') return;
example
code
const parsed = JSON.parse(data);
                const content = parsed.choices[0]?.delta?.content;
                if (content) {
                    document.getElementById('output').textContent += content;
                }
            }
        }
    }

}

Node.js Backend (Proxy Streaming to Client)

javascript

// Express server that proxies streaming from OpenAI to the browser

snippet
code
app.post('/api/chat', async (req, res) => {
// Set SSE headers
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
const stream = await openai.chat.completions.create({
        model: 'gpt-4o',
        messages: req.body.messages,
        stream: true,
    });
for await (const chunk of stream) {
        const content = chunk.choices[0]?.delta?.content;
        if (content) {
            res.write(`data: ${JSON.stringify({ content })}\n\n`);
        }
    }
res.write('data: [DONE]\n\n');
    res.end();

});

๐Ÿ“Š

The Streaming Response Format

Non-streaming and streaming responses have different structures:

Non-Streaming Response

json

{

example
code
"choices": [{
        "message": {
            "role": "assistant",
            "content": "The capital of France is Paris."
        },
        "finish_reason": "stop"
    }],
    "usage": {
        "prompt_tokens": 15,
        "completion_tokens": 8,
        "total_tokens": 23
    }

}

Streaming Chunks

json

// First chunk (role)

{"choices": [{"delta": {"role": "assistant", "content": ""}}]}

// Content chunks

{"choices": [{"delta": {"content": "The"}}]}

{"choices": [{"delta": {"content": " capital"}}]}

{"choices": [{"delta": {"content": " of"}}]}

// Final chunk

{"choices": [{"delta": {}}, "finish_reason": "stop"]}

snippet
code
// Usage (if stream_options.include_usage=true)

{"usage": {"prompt_tokens": 15, "completion_tokens": 8}}

Notice: streaming uses delta instead of message. Each delta contains only the new content, not the full message so far. You assemble the full message client-side.

Streaming with Tool Calls

Tool calls stream differently โ€” instead of content tokens, you get function call fragments:

json

// Tool call starts

{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_abc", "function": {"name": "get_weather", "arguments": ""}}]}}]}

// Arguments stream in chunks

{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"ci"}}]}}]}

{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "ty\":"}}]}}]}

{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": " \"Mum"}}]}}]}

{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "bai\"}"}}]}}]}

You need to accumulate the argument fragments and parse the complete JSON once streaming finishes:

python

snippet
code
tool_calls = {}

for chunk in stream:

example
code
delta = chunk.choices[0].delta
example
code
if delta.tool_calls:
        for tc in delta.tool_calls:
            idx = tc.index
            if idx not in tool_calls:
                tool_calls[idx] = {"id": tc.id, "name": tc.function.name, "args": ""}
            if tc.function.arguments:
                tool_calls[idx]["args"] += tc.function.arguments

After streaming completes:

for tc in tool_calls.values():

example
code
args = json.loads(tc["args"])  # Now parse complete JSON
    result = execute_tool(tc["name"], args)
๐Ÿ’ก

Why Streaming Feels Faster (Psychology)

The actual total time is the same or even slightly longer with streaming (SSE overhead). But it feels 3-5x faster because of how human perception works:

  1. Progress indicator effect. Seeing tokens appear tells your brain "it's working." A blank screen creates uncertainty โ€” is it broken? Is it thinking? Is it slow?
  1. Read-while-generating. A 500-token response takes 5 seconds to generate. Without streaming, you wait 5 seconds then start reading (total: 5 + reading time). With streaming, you start reading at 200ms. By the time the last token arrives, you've already read most of the response.
  1. Engagement bias. Watching text appear is more engaging than waiting. Engaged time feels shorter than idle time. Same reason a cricket match feels shorter than waiting in a queue.

๐Ÿ”ฌ

When NOT to Stream

Streaming isn't always the right choice:

Scenario Stream? Why

Chat interfaceโœ… YesUsers expect real-time typing
API backend processingโŒ NoNo human watching, simpler code
Tool-heavy agentsโš ๏ธ MaybeTool calls need full JSON before execution
Batch processingโŒ NoProcessing thousands of requests, no UI
JSON/structured outputโš ๏ธ MaybeCan't use partial JSON, but can show progress
Caching responsesโŒ NoEasier to cache complete responses

๐Ÿ›ก๏ธ

Chunked Transfer Encoding

Under the hood, SSE uses chunked transfer encoding โ€” an HTTP/1.1 feature that allows sending data in pieces without knowing the total size upfront:

HTTP/1.1 200 OK

Transfer-Encoding: chunked

1a\r\n

data: {"content":"Hello"}\r\n

\r\n

1c\r\n

data: {"content":" world"}\r\n

\r\n

0\r\n

\r\n

Each chunk is prefixed with its size in hexadecimal. The final chunk has size 0, signaling the end. Most HTTP libraries handle this transparently โ€” you just read the stream.

๐Ÿ“ฆ

Performance Numbers

Real-world streaming performance for major providers:

ProviderTTFT (Time to First Token)Tokens/SecondNotes
GPT-4o200-500ms80-100 t/sFastest for large model
Claude 3.5 Sonnet300-800ms60-80 t/sGood balance
Gemini 1.5 Pro200-400ms100-150 t/sFastest TTFT
Groq (Llama 3)50-100ms500-800 t/sCustom hardware, insane speed
Ollama (local)100-500ms20-60 t/sDepends on hardware

TTFT matters most for user experience. A model that starts responding in 100ms feels instant, even if total generation takes 10 seconds.

๐Ÿš€

Practical Takeaways

Always stream for user-facing interfaces โ€” the UX difference is dramatic

Skip streaming for backend processing โ€” simpler code, same total time

TTFT matters more than total speed โ€” users judge by how fast the first word appears

Accumulate tool call arguments โ€” they stream in fragments, parse after completion

SSE is just HTTP with a special content type โ€” no WebSocket, no special protocol

Streaming makes the same speed feel 3-5x faster โ€” it's psychology, not physics

๐Ÿ”„

What's Next?

Episode 38: Prompt Caching โ€” What if the API could remember your system prompt and skip re-processing it every time? Anthropic and OpenAI both offer prompt caching, and it can cut your costs by 90%. How it works, when it kicks in, and how to design prompts for maximum cache hits.

โ† Previous

Ep 36: The OpenAI API Spec

Next โ†’

Ep 38: Prompt Caching

Next: Episode 38 โ€” Prompt Caching

Every API call re-reads your entire system prompt. Every. Single. Time. Prompt caching fixes this โ€” and slashes your bill.

This is part of a 98-episode series covering AI engineering from tokens to production deployment.

โ† Previous Ep 36: The OpenAI API Spec: The Standard Everyone Follows