System Design
Intermediate 13 min read AI-era system design

Streaming LLM responses at scale

In one line

Streaming sends tokens to the user as the model generates them, usually over Server-Sent Events, which makes slow answers feel fast but turns every request into a long-lived connection that needs flushing, heartbeats, backpressure, cancellation and a resume plan.

What it is

An LLM writes its answer one token at a time. Without streaming, the user stares at a spinner for the full 5 to 30 seconds. With streaming, text starts appearing after the time to first token (TTFT), often under a second, and keeps flowing. Users judge AI features by TTFT much more than by total time.

Server-Sent Events (SSE) is the usual transport: a normal HTTP response with Content-Type text/event-stream that stays open while the server writes small 'data:' events. It is one-way (server to client), works through most proxies and HTTP/2, and has a standard reconnect mechanism with Last-Event-ID. Model providers stream to your backend the same way. WebSockets are two-way and are worth their extra operational cost only when the client also sends frequent messages, such as voice or collaborative editing.

At scale the challenge is not the protocol but the shape of the load. A request that used to last 50 ms now lasts 20 seconds, so the number of open connections, the memory per connection, proxy timeouts, slow mobile clients and what happens when a connection drops halfway become the main design problems.

Why interviewers ask

Any 'design ChatGPT', AI assistant or AI support prompt includes streaming, and interviewers use it to check real-world networking knowledge: Little's Law for concurrent connections, why proxies break streaming, how to stop paying for tokens when users leave, how to resume after a dropped mobile connection, and SSE vs WebSocket with a reason. Saying 'we use WebSockets' without reasons, or ignoring buffering and reconnects, is a common way to lose points.

How it works

  1. 1Client sends the message with POST (the browser's EventSource API only supports GET without a body or custom headers, so chat apps usually use fetch() and read the response body as a stream, parsing the SSE format themselves).
  2. 2The server persists the user message, assigns a message id for the reply, and opens a streaming call to the AI gateway, which opens one to the provider.
  3. 3The server responds with 200, Content-Type text/event-stream, Cache-Control no-cache, and disables proxy buffering (for Nginx, the X-Accel-Buffering: no header or proxy_buffering off). Buffering proxies hold the whole response and destroy streaming.
  4. 4For each chunk from the model, write an event with an increasing id and flush immediately. Batch tiny tokens into a write every 30 to 50 ms if needed; it cuts syscalls and network packets with no visible delay.
  5. 5Send a heartbeat comment (a line starting with ':') every 10 to 15 seconds while waiting, because load balancers and proxies close idle connections (AWS ALB and Nginx both default to 60 seconds).
  6. 6Bound everything: each connection gets a small bounded buffer. If a slow client cannot keep up, either drop it or switch it to catch-up mode; never let one slow reader block a shared loop or grow memory without limit.
  7. 7Propagate cancellation: when the client disconnects or presses Stop, cancel the request context so the gateway closes the provider stream. Every token generated after that is money spent for nothing.
  8. 8Finish cleanly: send a final event with the finish reason and token usage, persist the full assistant message, and record TTFT, tokens per second and total time.
  9. 9Plan for drops: either the generation is tied to the connection (cancel on disconnect, simplest and cheapest), or generation runs detached and appends chunks to a store such as a Redis Stream keyed by message id, so a reconnecting client asks for 'everything after event id 412' and continues.

Worked example · Capacity for a consumer chat assistant at evening peak

  1. Peak: 2,000 new messages per second; answers average 400 tokens at 40 tokens per second, so a stream lasts about 10 s plus 1 s TTFT.
  2. Concurrent streams (Little's Law): 2,000 x 11 s = 22,000 open connections, plus users who keep the page open with heartbeats.
  3. Memory: about 64 KB per connection (goroutine, buffers, TLS state) x 22,000 = about 1.4 GB. Spread over 12 pods, about 120 MB each, which is fine; file descriptor limits must be raised above the default 1,024.
  4. Write rate: 22,000 streams x 40 tokens/s = 880,000 tokens per second. Sending each token as its own event would be 880,000 writes per second; batching every 50 ms gives 22,000 x 20 = 440,000 writes per second, and the payloads are larger but fewer.
  5. Stop button: analytics show 12% of streams are abandoned on average 4 s in. With cancellation, those streams stop at about 160 tokens instead of 400, saving about 7% of output tokens across all traffic.
  6. Deploys: each pod holds about 1,800 streams. A rolling restart must drain: mark the pod not ready, stop accepting new streams, and allow up to 60 s for open streams to finish before shutting down, or clients see cut-off answers.
  7. Mobile drops: 3% of streams lose the connection mid-answer on patchy 4G. With detached generation and a Redis Stream per message (TTL 10 minutes), the client reconnects with Last-Event-ID and resumes; without it, the user sees half an answer and retries, paying twice.

Picture it

Streaming is like a live cricket commentary instead of a match report the next morning. Listeners hear every ball as it happens and stay engaged, but the broadcaster must keep the line open for hours, cope with listeners whose radio drops out, and stop broadcasting to people who have switched off.

Reference code

func streamChat(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }
    ctx := r.Context() // cancelled when the client disconnects
    tokens, err := gateway.Stream(ctx, buildRequest(r))
    if err != nil {
        http.Error(w, "model unavailable", http.StatusServiceUnavailable)
        return
    }
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("X-Accel-Buffering", "no") // stop Nginx buffering

    ping := time.NewTicker(15 * time.Second)
    defer ping.Stop()
    for seq := 1; ; {
        select {
        case <-ctx.Done():
            return // gateway.Stream sees ctx cancel and closes the provider call
        case <-ping.C:
            fmt.Fprint(w, ": ping\n\n")
            flusher.Flush()
        case t, open := <-tokens:
            if !open {
                fmt.Fprint(w, "event: done\ndata: {}\n\n")
                flusher.Flush()
                return
            }
            fmt.Fprintf(w, "id: %d\ndata: %s\n\n", seq, t.JSON())
            flusher.Flush()
            seq++
        }
    }
}

Key patterns

SSE by default, WebSocket when the client talks a lot

When: Choosing the transport

How: SSE for token streams and server pushes over plain HTTP; WebSocket for voice, collaborative editing or games where the client sends many messages.

Detached generation with a replay log

When: Unreliable networks or long answers users must not lose

How: A worker writes chunks to a Redis Stream or similar keyed by message id; clients read and resume from Last-Event-ID; persist the final message.

Cancel on stop

When: Every streaming call

How: Tie the upstream call to a cancellable context; the Stop button or a disconnect (when generation is tied to the connection) closes the provider stream.

Bounded per-connection buffers

When: Fan-out to many clients

How: Each connection has a small queue; when full, disconnect or switch the client to catch-up from the log, so memory stays flat.

Real-world use

OpenAI and Anthropic APIs

Both stream completions as Server-Sent Events. Anthropic sends typed events (message_start, content_block_delta, message_delta, message_stop); OpenAI's chat completions stream ends with a 'data: [DONE]' line. Your backend parses these and re-streams to its own clients.

ChatGPT and similar chat apps

Chat UIs render tokens as they arrive and offer a Stop button, which on the backend should cancel generation. Products that let you refresh and still see the full answer have decoupled generation from the browser connection.

Vercel AI SDK

A popular TypeScript toolkit whose helpers stream model output from server routes to React components over HTTP streaming, showing how standard the stream-through pattern has become in web apps.

Nginx proxy buffering

Nginx buffers upstream responses by default; streaming endpoints set proxy_buffering off or send X-Accel-Buffering: no. Forgetting this is one of the most common reasons streaming works locally and fails in production.

In a project

An edtech company in Kota runs an AI doubt-solving tutor for JEE students. Evenings bring 15,000 concurrent students, many on low-end Android phones and patchy mobile data. Students complain that answers 'hang' and then appear all at once, or stop halfway.

  1. Trace one request: the answers arrive at the server as a stream but the CDN and Nginx layer buffer them. Disable buffering for the /chat/stream route and confirm TTFT in real browsers drops from 9 s to about 0.8 s.
  2. Add a heartbeat comment every 15 s and raise proxy read timeouts for the streaming route only, to stop the 60-second idle cut-offs on long derivations.
  3. Generate answers in a worker that writes chunks to a Redis Stream per message; the SSE endpoint reads from it. A dropped phone reconnects with Last-Event-ID and continues, and a refresh shows the finished answer.
  4. Keep a 10-minute TTL on streams and persist the final answer to Postgres; cancel generation only when the student presses Stop, not on disconnect, because mobile disconnects are usually accidental.
  5. Batch tokens every 50 ms and render math (LaTeX) only at line ends so the low-end phones' UI thread is not overwhelmed.
  6. Drain pods for 90 s on deploy and track TTFT p95, stream completion rate and reconnect rate per network type.

Impact: Median TTFT falls below 1 s, the share of answers that never complete drops from 6% to under 0.5%, and fewer students press 'retry', which also lowers token spend by several percent.

Trade-offs

Use it when

  • A person is waiting for generated text: chat, drafting, explanations, code suggestions
  • Answers take more than about a second to complete
  • You want users to be able to stop generation early and save tokens

Avoid it when

  • The output must be validated as a whole before anyone sees it, such as JSON consumed by code or answers that need a full moderation check; generate, validate, then return
  • Nobody is waiting (batch jobs, background summaries): use a queue and notify on completion
  • Very short outputs such as a classification label, where streaming adds complexity and no visible benefit

Common mistakes

  • Leaving proxy or CDN buffering on, so tokens arrive all at once at the end.
  • Not flushing after each write, with the same effect.
  • No heartbeats, so long answers are cut at 60 seconds by a load balancer idle timeout.
  • Not cancelling the provider call when the user stops or leaves, paying for tokens nobody sees.
  • Sizing servers by requests per second instead of concurrent connections, and forgetting file descriptor limits.
  • Writing to all clients from one shared loop without per-connection buffers, so one slow phone stalls everyone.
  • Rolling deploys that kill pods immediately, cutting thousands of answers in half.
  • Using WebSockets by default and then having to build sticky sessions and reconnect logic that SSE gives more simply.

Interview questions

Quick revision

  • Users feel TTFT; stream whenever a person waits for more than about a second.
  • SSE: text/event-stream, 'id:' and 'data:' lines, blank line ends an event, Last-Event-ID to resume.
  • EventSource is GET-only; chat apps use fetch() streaming to POST.
  • Disable proxy buffering (X-Accel-Buffering: no), flush after each write.
  • Heartbeat every 10-15 s; default idle timeouts are often 60 s.
  • Concurrent streams = message rate x stream duration; size memory and file descriptors for it.
  • Batch tokens every 30-50 ms to cut write rate.
  • Cancel upstream on Stop; decide whether disconnect also cancels.
  • Resume: detached generation + Redis Stream per message + Last-Event-ID.
  • Drain long-lived connections before shutting pods down.

Check yourself

1. Your chat endpoint streams correctly when called directly, but through Nginx the answer arrives all at once. What is the most likely cause?

2. Long answers are cut off at exactly 60 seconds in production. What is the best fix?

3. Why should the backend cancel the provider call when the user presses Stop?

4. At peak, 1,500 messages per second start streaming and each stream lasts 12 seconds. How many concurrent connections should you plan for?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free