Design a high-concurrency streaming API server
The interviewer asks
“Design the API tier that streams LLM answers token by token to 200,000 concurrent users (web and mobile), where each response lasts 5-60 seconds. It must not collapse under overload, must handle slow mobile clients and disconnects, and must deploy without dropping in-flight streams.”
What a strong answer looks like
A strong answer starts with Little's Law: concurrent streams = arrival rate × duration, so 10,000 new chats per second lasting 20 s means 200,000 open connections, and the server is limited by memory, file descriptors and downstream pools long before CPU. Use SSE over HTTP/2 for one-way token streaming (WebSockets only if the client sends a lot), behind load balancers and proxies with buffering disabled and long idle timeouts. Each server uses an event loop or goroutines with tiny per-connection state, bounded per-connection buffers, and admission control: a concurrency limit per instance and per tenant that returns a fast 429/503 with Retry-After instead of queueing forever. Propagate client disconnects as context cancellation so the upstream LLM call stops (and stops costing money). Make streams resumable: generation runs decoupled from the connection and writes tokens to a short-lived buffer (Redis Streams), so a reconnect with Last-Event-ID resumes. Deploys drain gracefully. Metrics: TTFT, tokens/s, active streams, shed rate.
Concepts to mention
1 Requirements
Functional
- POST a chat message and receive the answer as a stream of tokens (SSE), plus final usage and citations
- Clients can cancel a generation; closing the tab or app must stop upstream work
- Resume a stream after a network drop (mobile switching from Wi-Fi to 4G) without restarting generation
- Same server also pushes small live updates (e.g. job status) to connected clients
- Per-user and per-tenant limits on concurrent streams
- Graceful behaviour under overload: clear 429/503 with Retry-After
Non-functional
- 200,000 concurrent streams at peak, ~10,000 new streams/s
- Server-added time to first token under 50 ms (TTFT is dominated by the model, typically 300-1,500 ms)
- Memory per idle-ish stream under ~50-100 KB so a 32 GB instance can hold 20-40k streams
- Zero dropped streams during rolling deploys
- Overload never causes latency collapse for admitted streams: p99 inter-token gap stays flat
- Availability 99.95%; survives loss of one availability zone
Out of scope (say it out loud)
- Model serving and GPU inference (behind the LLM gateway)
- Prompt construction, RAG retrieval and safety filters (upstream services)
- Voice / bidirectional real-time audio
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/chats/{chat_id}/messages | Submit a message; returns 202 with generation_id (or 429 if over concurrency). Idempotency-Key header prevents duplicate generations on retries. |
SSE/v1/generations/{generation_id}/stream (Last-Event-ID optional) | Stream events: token deltas with sequential ids, heartbeat comments every 15 s, final 'done' with usage. Resumes from Last-Event-ID. |
POST/v1/generations/{generation_id}:cancel | Explicit stop; cancels upstream LLM call and marks the generation cancelled. |
SSE/v1/events | Per-user channel for small live updates (job finished, new message in a shared chat). |
GET/readyz and /healthz | Readiness flips to failing on SIGTERM so the load balancer stops sending new streams; liveness stays healthy while draining. |
4 Data model
generation bufferRedis Streams (sharded by generation_id)stream key gen:{id}entries {seqdelta_text}; meta hash {statususer_idmodelcreated_at}; TTL 10 min after doneDecouples generation from the connection: the writer appends, any server can read from seq N. Small (a few KB per generation) and short-lived.
connection registryRedis (optional)user:{id}:conns → instance idsOnly for routing pushes on /v1/events; streams themselves need no sticky routing because the buffer is shared.
concurrency countersRedis + localconc:{tenant} and conc:{user} counts with lease TTLIncremented on stream start, decremented on end; leases expire so a crashed instance does not hold slots forever.
messagesPostgreschat_idmessage_idrolecontentgeneration_idusagecreated_atFinal answer persisted once at 'done' (not per token), so the DB sees 10k writes/s, not millions.
metricsPrometheusactive_streamsttft_secondsinter_token_gapshed_total{reason}slow_client_disconnectsLow-cardinality labels only (instance, route, tenant tier).
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Stream an answer
- 1The app POSTs the message with an Idempotency-Key. The chat API checks the user's and tenant's concurrent-stream limits (e.g. 3 per user, 2,000 per tenant) and the instance's admission semaphore; if full it returns 429 or 503 with Retry-After in under 5 ms.
- 2Admitted: it creates generation_id, takes a concurrency lease, and hands the job to a generation worker (in-process for simplicity, or via a queue). It returns 202 with the generation_id.
- 3The client opens GET /v1/generations/{id}/stream. The L7 load balancer has response buffering off and an idle timeout of 5+ minutes; the server sets Content-Type text/event-stream, Cache-Control no-cache and X-Accel-Buffering: no.
- 4The generation worker calls the LLM gateway with a context deadline (e.g. 120 s) and streaming enabled; as deltas arrive it batches them every ~30 ms and XADDs to gen:{id} with sequence numbers.
- 5The stream server XREADs (blocking) from gen:{id} and writes each batch as an SSE event with id = seq, flushing after every write. A heartbeat comment every 15 s keeps proxies and mobile carriers from closing the idle connection.
- 6On completion the worker appends a 'done' event with usage and citations, persists the full message to Postgres once, releases the concurrency lease and sets a 10-minute TTL on the buffer.
Mobile network switch and resume
- 1Halfway through, the phone moves from Wi-Fi to 4G and the TCP connection dies. The generation worker is unaffected and keeps writing tokens to Redis.
- 2The client reconnects (EventSource does this automatically) with Last-Event-ID: 412; the load balancer may route it to a different stream server.
- 3That server XREADs gen:{id} from seq 413, sends the missed tokens in one burst, then continues live.
- 4If the client does not come back within ~30-60 s and the user did not ask for background completion, the stream server's absence is noticed (no reader lease), and the worker cancels the upstream call to save cost.
7 Deep dives
Concurrency model and bounded resources
Two hundred thousand mostly idle connections are cheap only if per-connection state is small. One OS thread per connection (classic Tomcat with blocking IO) costs ~1 MB of stack each and context switching, so it tops out at a few thousand. Use an event-loop or lightweight-task model: Go goroutines (a few KB of stack, multiplexed on OS threads with epoll), Rust Tokio tasks, Node.js, or Java virtual threads / Netty. Then bound everything, because unbounded anything becomes the outage. Per instance: an admission semaphore capping active streams (e.g. 20,000), and a fast rejection when it is full. Per connection: a small send buffer or channel (e.g. 64 events); the writer never blocks the producer. Per downstream: fixed connection pools (DB, Redis) and an HTTP/2 client to the LLM gateway with a maximum number of concurrent streams. Every operation has a timeout: read-header timeout (stops slowloris), idle timeout, and per-call deadlines carried in context. Raise OS limits: file descriptors (ulimit -n), somaxconn, ephemeral port range for upstream connections, and tune TCP buffer sizes, since default kernel buffers per socket can add up to gigabytes at 200k connections. Measure with a load test at rising concurrency and watch memory, goroutine count and open FDs, not just latency.
Backpressure, slow clients and overload
A client on a weak 2G or crowded stadium network may read slower than tokens are produced. If the server buffers without limit, memory grows until the process is killed and every user on that instance loses their stream. With per-connection bounded buffers, a full buffer means the client is too slow: for token streams, coalesce (merge queued deltas into one larger event, which is lossless because order is kept), and if the client is still behind after a timeout (e.g. 30 s with no progress), close the connection and let it resume from the Redis buffer later. For status updates, drop intermediate states and send the latest. Overload at the instance level is handled by admission control, not queues: when active streams reach the cap, new requests get 503 with Retry-After immediately, so admitted users keep a flat inter-token latency instead of everyone getting slow (the latency collapse of an overloaded server). Load balancers should spread by least connections, not round robin, because streams are long-lived and uneven. Also shed load upstream: if the LLM gateway returns 429 (provider rate limit), reject new generations early rather than letting them queue for minutes. Expose shed counts by reason so capacity planning is based on data.
Decoupling generation from the connection
The simplest design proxies the LLM stream directly to the client connection. It has two problems. A network blip kills the generation, so the user must start over and you pay for the tokens twice; and a disconnect is the only way to cancel. Decoupling fixes this: a generation worker owns the upstream call and appends tokens with sequence numbers to a short-lived buffer (Redis Streams, NATS JetStream, or in-memory with sticky routing for simpler setups). Any stream server can serve any reader from any sequence number, so SSE's built-in Last-Event-ID reconnect gives real resume, and the load balancer needs no sticky sessions. Cancellation becomes explicit: the user presses stop (POST :cancel), or no reader has been attached for N seconds, and the worker cancels the upstream request's context, which closes the HTTP/2 stream to the provider and stops token billing. Persist the final message once at completion; persisting every token to Postgres would turn 6 million events per second into database writes. The cost is an extra hop (~1 ms within a zone) and a Redis tier sized for the write rate; batching tokens every 30-50 ms keeps it manageable without users noticing.
Deploys and graceful shutdown with long streams
Streams that last 60 seconds make naive rolling deploys drop users. Graceful shutdown in order: on SIGTERM, flip /readyz to failing so the load balancer stops sending new connections (wait ~5-10 s for it to notice), stop accepting new streams, let active streams finish up to a deadline (e.g. 90 s, matching terminationGracePeriodSeconds), then send remaining clients a 'reconnect' event or just close, and they resume on another instance from the Redis buffer with Last-Event-ID. Because generation lives in workers decoupled from stream servers, restarting a stream server never loses work. Generation workers drain the same way: finish current generations, take no new ones. Roll out slowly (maxUnavailable 10%) so the remaining instances can absorb reconnects, and watch for reconnect storms: thousands of clients reconnecting at the same moment should use jittered backoff, and SSE's retry field lets the server set it. For WebSocket-based designs the same applies, plus a protocol-level 'go away' message.
8 Trade-offs
SSE vs WebSockets vs long polling
SSE is plain HTTP, works through most proxies, has built-in reconnect with Last-Event-ID, multiplexes over HTTP/2, and fits one-way token streams. WebSockets are bidirectional and needed for frequent client messages (collaboration, voice), but need custom reconnect/resume, sticky handling and more proxy care. Long polling works everywhere but adds latency and request overhead per token batch. SSE is the default for LLM streaming; use HTTP/2 to avoid the browser limit of ~6 connections per domain under HTTP/1.1.
Direct proxy vs decoupled buffer
Direct proxying is simpler and has one less hop and component, fine for small scale. The buffer adds Redis cost and complexity but gives resume, explicit cancel, stateless stream servers and safe deploys. At 200k concurrent mobile users, resumability pays for itself.
Per-token events vs batched events
One event per token gives the smoothest typing effect but multiplies syscalls, Redis writes and bandwidth. Batching every 30-50 ms is visually the same to users and cuts writes by 2-5x.
9 Bottlenecks
TLS handshakes during reconnect storms
If an AZ fails, ~70k clients reconnect at once and TLS handshakes are CPU-heavy. Terminate TLS at the load balancer or use session resumption, keep spare capacity (N+1 AZ), and use jittered client retry.
Redis write rate
Millions of tiny XADDs per second can saturate Redis. Batch tokens, shard by generation_id across a cluster, and keep entries small; for very large scale, keep the buffer in the generation worker's memory with consistent routing and use Redis only as a fallback.
LLM provider throughput
The real limit is often the provider's tokens per minute. Admission control should use the LLM gateway's budget signal so the server rejects early instead of holding thousands of connections waiting for capacity.
10 Failure handling
Stream server crashes
Its clients' connections drop; they reconnect (to other instances) with Last-Event-ID and resume from Redis. No generation is lost because generations run in workers.
Generation worker crashes mid-answer
The stream shows no progress; a watchdog sees the generation's heartbeat expire, marks it failed, and the client gets an error event with a 'regenerate' option. Billing uses only reported usage.
LLM provider slow or down
The gateway's circuit breaker fails over to a backup model; if none is available, new generations are rejected quickly with a clear error, and active streams get a timeout error event instead of hanging forever (per-call deadline).
11 What impresses
- Starts with Little's Law and sizes memory and file descriptors per stream
- Shows that overload must shed early to keep admitted streams fast, not queue forever
- Decouples generation from the connection so mobile network switches resume instead of restarting
- Explicitly cancels upstream calls on disconnect to save cost
- Mentions proxy buffering, idle timeouts and heartbeats as real-world stream killers
- Plans deploys around 60-second streams with readiness flip and drain
12 Mistakes
- Thread-per-connection servers for hundreds of thousands of long-lived streams
- Unbounded buffers or goroutines per request, leading to OOM under slow clients
- Forgetting that Nginx or the load balancer buffers responses, which kills streaming
- Not cancelling the LLM call when the user leaves
- Writing every token to the database
- Round-robin load balancing for long-lived connections
13 Follow-ups
Lessons this design uses
Practice questions
- Your chat API streams LLM answers over SSE. At 500 new chats per second, each streaming for about 20 seconds, servers run out of memory and DB connections even though CPU is at 30%. How do you design the serving tier?
- Design the API for a chat assistant that streams LLM responses token by token to web and mobile clients.
- You need to push live updates to clients. When would you choose WebSockets, Server-Sent Events or long polling?
- You need to support 10 million concurrent WebSocket connections. How do you estimate how many gateway servers you need, and what limits a single server?
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.