HardProblem 31 of 36 in the system design series
Design ChatGPT: Streaming LLM Chat on Scarce, Costly GPUs

A ChatGPT-like product looks simple from the outside. Someone types a question into a text box, and a large language model answers it, with the words flowing onto the page a few at a time. Behind that box sits a service that has to accept a billion prompts a day, keep every conversation, and push each answer to the browser while the model is still writing it.
Interviewers like this question because it breaks the usual pattern. In most designs the database or the cache is the hard part. Here the scarce resource is GPU time: every reply holds an accelerator for several seconds, the hardware is expensive, and you cannot simply add more of it during a spike. A strong answer therefore treats compute as a budget to protect. You need queues in front of the model, batching on the GPU, per-user limits, and a plan for chats that grow longer than the model can read in one pass.
This is an interview practice design for a ChatGPT-like service, built from first principles. It is not a description of how ChatGPT is actually built.
Functional Requirements
Four behaviours define the product. A user submits a prompt and receives an answer. That answer is streamed, so text shows up while it is being produced rather than after a long silence. Conversations persist: old chats appear in a sidebar, and each follow-up question is answered with the earlier turns in mind, which is what makes "make it shorter" a valid request. Finally, the user stays in control and can halt a reply that is going the wrong way or ask for a fresh attempt.
Model training, file attachments, voice mode and tool calling are deliberately left out. Each of them is a large design on its own, and including them would hide the core problem: serving inference to millions of people with a limited fleet of GPUs.
Send a message
The user types a prompt and gets an answer from the model.
prompt → reply
Stream the reply
Words appear as they are generated, not all at the end.
token by token
Conversations
Keep chat history, and use it as context for follow-ups.
multi-turn
Stop & regenerate
Cancel a reply midway, or ask for a new one.
user control
- Out of scope today: model training, file uploads, voice and tools
Non-Functional Requirements
The most visible quality target is time to first token, which we set below one second. People will happily read a long answer, but a blank response area feels broken after a moment or two. Streaming exists mainly to hit this number.
Behind the scenes, GPU utilisation is the cost lever. An accelerator that sits idle between requests is burning money, so the serving layer must keep each one packed with work. Fairness matters for the same reason: if one account submits enormous prompts in a loop, quotas and priorities must stop it from pushing everyone else into a slow queue. Durability is the last requirement. Losing a conversation that a user saved is a trust problem, so writes to history must survive crashes and disconnects.
Fast first token
The reply should start appearing almost immediately.
< 1 s to first token
Efficient GPU use
GPUs are the most expensive part, so keep them busy.
high utilisation
Fair under load
One heavy user must not slow down everyone else.
quotas · priorities
Durable history
A conversation, once saved, is never lost.
no lost chats
Scale Estimation
Assume roughly 200 million daily users who each send about 5 messages. That gives 1 billion messages per day, or about 12 thousand per second on average and something near 35 thousand at the evening peak. If a typical answer runs to around 500 tokens, the fleet must produce about 5.8 million tokens every second, which translates into thousands of GPUs running continuously.
Storage barely registers next to that. At around 2 KB for a message plus its reply, history grows by about 2 TB a day, a volume any partitioned database handles comfortably. The arithmetic points to one conclusion: the expensive, constrained piece is inference capacity, so the architecture must queue requests, batch them on the hardware, and limit how much any one user can consume.
1billion
Messages per day
200M users × 5 messages
12K avg
Messages per second
≈ 35K at peak
5.8million
Tokens generated / sec
≈ 500 tokens per reply
2TB
History per day
2 KB per message
GPU capacity is the bottleneck → queue, batch and limit
Core Entities & API
The data model has four entities: a User owns many Conversations, a Conversation holds an ordered list of Messages, and each Conversation is tied to a Model. Keeping the model on the conversation lets different chats use different model sizes without changing the message format.
Three endpoints cover the core flow. POST /conversations creates an empty chat for a chosen model and returns its ID. POST /conversations/{id}/messages submits a prompt, and instead of a single JSON body it returns a server-sent event stream that delivers tokens as they are produced. GET /conversations/{id} loads the title and full message list when someone reopens an old chat. The unusual part is the streaming response; everything else is ordinary request and reply.
Core entities
UserConversationMessageModel
API
- POST /conversations { model }→ { conversation_id }
- POST /conversations/{id}/messages { content }→ SSE stream of tokens
- GET /conversations/{id}→ { title, messages[] }
High-Level Design
A prompt enters through the API gateway, which authenticates the caller and routes the call. Before any real work starts, the gateway asks a Redis-backed rate limiter whether this user still has quota. That check is cheap, and it guards the most expensive resource in the building, so it belongs at the front door.
The chat service then does the bookkeeping. It persists the new user message, reads earlier turns from the conversation database, and assembles the full prompt the model will see. It also sends the input through a moderation check, because rejecting a harmful request costs milliseconds while generating a reply for it would cost seconds of GPU time.
The assembled prompt goes to the inference router, which holds a queue and dispatches each request to a GPU worker that has spare room. The worker produces tokens one by one; they travel back through the chat service and out to the client over SSE. When generation finishes, the chat service writes the completed reply to the database so the conversation stays whole.
Tokens stream back GPU → chat service → client over SSE
Deep Dive 1 — Streaming Tokens
Returning the answer in one piece is the simplest contract, but a long reply can take ten seconds or more, which ruins the first-token goal and risks gateway timeouts. WebSockets give low-latency, bidirectional messaging, yet each connection is stateful and pins a user to one server, which complicates load balancing and deploys.
Server-sent events fit the actual traffic shape. The client opens a normal HTTP request, and the server leaves the response open, writing each token as an event. Proxies and load balancers already understand this, and the only direction we need is server to client. That makes SSE the pick.
Two details complete the design. Since SSE carries no messages upward, the stop button sends a separate cancel request, which tells the worker to abandon the generation and release its batch slot immediately. And the server assembles and stores the full reply on its own, so if a laptop lid closes mid-answer, the text is waiting when the user returns.
Wait for full reply
- + Simplest API
- − 10+ seconds of blank screen
- − Timeouts on long replies
WebSocket
- + Two-way, real-time
- − Stateful connections
- − Harder to load balance
Server-sent eventsChosen
- + Plain HTTP, one-way push
- + Works with proxies & LBs
- − Server to client only
- Stop button → a cancel call frees the GPU slot right away
- Save the reply on the server, even if the client disconnects
Deep Dive 2 — GPU Inference Serving
Serving the model is where cost is won or lost, and it works as a pipeline. Admission comes first: each request is checked against the user's limits, and anyone over quota gets a 429 straight away rather than waiting in line for capacity they cannot use. Accepted requests join a queue for their target model, where paid plans can be given higher priority. The router only hands work to a worker that reports free room, so requests wait briefly instead of timing out on an overloaded machine.
On the worker, continuous batching keeps the hardware busy. A GPU computes the next token for many sequences almost as cheaply as for one, so at each decoding step the worker admits newly arrived requests into the running batch and drops those that finished. Nobody waits for the slowest sequence in a fixed batch to complete.
Memory, not raw compute, usually sets the ceiling. Each active sequence keeps a key-value cache of its attention state in GPU memory, and a long prompt makes that cache large, so the number of requests that fit at once depends on their combined length. When queues grow, the autoscaler adds workers; if the fleet is still saturated, the system sheds load, for instance by serving free-tier users from a smaller, cheaper model.
- 1Admission & quotasPer-user limits; return 429 when over
- 2Queue per modelPriority by plan; short waits, not timeouts
- 3Continuous batchingMany requests share each GPU step
- 4Manage GPU memoryKV cache limits how many requests fit
- 5Scale & shed loadScale on queue depth; fall back when full
Deep Dive 3 — Conversations & Context
Chat history is stored partitioned by conversation ID and ordered by timestamp within each partition. Opening a chat then becomes a single sequential read rather than a scatter across nodes. A wide-column store in the style of Cassandra or DynamoDB matches this access pattern and scales horizontally as the daily 2 TB accumulates.
The model can only attend to a fixed number of tokens, so the chat service must choose what goes into each prompt. It always includes the system prompt, then adds messages from newest to oldest until the token budget is nearly full. For conversations that run much longer, older turns are compressed into a short summary so that facts stated early, such as a user's name or project details, survive.
Because each new turn starts with the same system prompt and history as the previous one, the GPU can reuse the cached computation for that prefix instead of rebuilding it, which shortens time to first token and saves compute. Work that does not affect the reply, like generating a chat title or indexing messages for search, runs in background jobs so it never delays generation.
Partition messages by conversation ID
One read gets a whole chat in order; Cassandra or DynamoDB style.
Fit the context window
Send the newest messages that fit the model's token budget.
Summarise older turns
Replace old messages with a short summary to keep key facts.
Prefix caching
Reuse the computed system prompt and history on the next turn.
Titles and search, async
A background job names chats and indexes them later.
Trade-offs
Batch size is the central dial. Larger batches spread each GPU across more users and cut cost per token, but every individual stream slows slightly. SSE keeps streaming simple and proxy-friendly, at the price of a separate endpoint for cancellation. Sending full history gives the model complete context but burns tokens on every turn, while summaries save tokens and can drop a detail the user later asks about.
Strict quotas protect capacity for the whole user base, yet at peak some people will hit a limit message. A good answer names these costs openly and explains why each choice still fits the goals: start streaming quickly, keep accelerators saturated, and defend the fleet with queues and quotas.
Bigger batches vs lower latency
More requests per GPU saves money, but each token comes a bit slower.
SSE vs WebSocket
Simple one-way streaming, but cancel needs a separate call.
Full history vs summary
Full history is accurate but costly; summaries are cheap but lose detail.
Strict quotas vs user experience
Protects GPU capacity, but some users see a limit message.
Stream early. Keep GPUs busy. Protect capacity with queues and quotas.
Key takeaways
- GPU time, not storage, is the bottleneck; every layer exists to spend it carefully.
- Stream tokens with server-sent events and offer a separate cancel call for the stop button.
- Check quotas before the queue so over-limit users never consume inference capacity.
- Continuous batching and prefix caching raise throughput per GPU; KV cache memory caps concurrency.
- Partition history by conversation ID and trim or summarise old turns to fit the context window.
Interview questions
How does ChatGPT stream responses word by word?
In this practice design the client sends the prompt as an ordinary HTTP POST, and the server answers with a server-sent event stream. Each token the GPU worker generates is relayed through the chat service and written as one event, so the browser can render text immediately. The connection stays open until generation ends or the user cancels.
Should an LLM chat app stream answers over SSE or WebSockets?
Traffic during a reply flows only from server to client, so a bidirectional channel adds cost without benefit. SSE runs over plain HTTP, which load balancers and proxies already handle well, and it avoids long-lived stateful sockets pinned to specific servers. The one gap, stopping a reply, is covered by a small separate cancel endpoint.
What is continuous batching in LLM inference?
Continuous batching lets a GPU worker change the members of its batch at every decoding step. New requests join as soon as there is room, and finished ones leave right away, instead of the whole batch waiting for its longest sequence. This keeps utilisation high and cuts queueing delay, which matters when GPUs are the main cost.
How do chatbots handle conversations longer than the context window?
The service builds each prompt within a token budget. It keeps the system prompt, then adds the most recent messages until the budget is nearly used. Older turns are replaced by a compact summary that preserves key facts. This trades some detail for lower cost and keeps every request within what the model can read.
How do you prevent one user from overloading GPU capacity?
Requests pass a Redis-backed rate limiter at the gateway and an admission check before the inference queue. Users over their quota receive a 429 immediately. Queues can rank paid plans higher, and when the fleet is saturated the system sheds load, for example by routing free users to a smaller model rather than letting everyone slow down.
Keep practising
- Learn the building blocks in the free system design course.
- Rehearse answers with system design interview questions.
- See which open jobs fit your skills: upload your resume.