Design a ChatGPT-like chat service
The interviewer asks
“Design a consumer chat assistant like ChatGPT: users have conversations with a large language model on web and mobile, answers stream token by token, and conversations are saved and synced across devices. Assume 50 million daily active users and that you run the models on your own GPU fleet.”
What a strong answer looks like
A strong answer separates three planes. The chat plane is ordinary but large-scale web backend work: auth, conversation storage as a message tree (for edits and regenerate), history sync, rate limits per plan. The streaming plane holds hundreds of thousands of long-lived SSE connections and must survive reconnects, so generation is decoupled from the connection by writing tokens to a short-lived stream (Redis Streams) the client can resume from. The inference plane is where the money is: a scheduler that queues requests by priority and model, and GPU servers using continuous batching, paged KV cache and prefix caching, sized from tokens per second, not requests per second. Around them: context assembly (truncation, summaries, memory), safety checks on input and streamed output, and graceful degradation when GPUs run out. Senior candidates do the GPU math, explain prefill vs decode, and design admission control by plan tier.
Concepts to mention
1 Requirements
Functional
- Send a message and receive a streamed answer token by token
- Conversation list and full history synced across web and mobile
- Edit a previous message or regenerate an answer, keeping alternative branches
- Stop generation mid-stream
- Model choice by plan (free: small model; paid: large model) with usage limits
- File and image upload as context (store and pass to the model)
- Share a conversation by public link
Non-functional
- Time to first token under 1 s p50 and 3 s p95 for paid users
- Stream speed at least 30 tokens/s per conversation
- 50M DAU, ~500M messages/day, peak ~20k new generations/s
- Chat history durable (no lost messages) with 99.95% availability for reads
- Graceful degradation under GPU shortage: queue or downgrade free users, protect paid users
- Stream resumes after a network drop without regenerating
Out of scope (say it out loud)
- Model training and fine-tuning
- Tools such as web browsing and code execution (mention as extensions)
- Billing and payments
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/conversations/{id}/messages | Send a message (content, parent_message_id, model). Returns 202 with message_id and generation_id; streaming happens on the next call. |
SSE/v1/generations/{generation_id}/stream?after={seq} | Stream tokens as numbered events; reconnect with Last-Event-ID resumes from the next token. |
POST/v1/generations/{generation_id}/cancel | Stop generation; the inference server frees the slot and the partial answer is saved. |
GET/v1/conversations?cursor= | Paged conversation list ordered by last activity. |
GET/v1/conversations/{id} | All messages as a tree (id, parent_id) plus the currently selected branch. |
POST/v1/conversations/{id}/share | Create a read-only snapshot link. |
4 Data model
conversationsCassandra / ScyllaDB (or DynamoDB)user_id (partition key)updated_at (clustering, desc)conversation_idtitlemodelcurrent_leaf_idOne partition per user gives the sidebar list in one read. Title is generated asynchronously by a small model.
messagesCassandra / ScyllaDBconversation_id (partition key)message_id (time-ordered, clustering)parent_idrolecontentstatus (streaming|done|stopped|error)modeltokenscreated_atparent_id makes a tree: edit and regenerate add siblings instead of overwriting. Large conversations stay under partition limits because messages are small; attachments live in S3.
generation_streamRedis Streams (TTL 10 min)gen:{generation_id} → entries (seq, token_text)final statusDecouples generation from the client connection. Any edge pod can serve the stream; the final text is persisted to messages when done.
usage_limitsRedislimit:{user_id}:{model}:{window} → count / tokensPlan limits like 'large model: 80 messages per 3 hours'; sliding window counters.
attachmentsS3 + metadata in Postgresfile_iduser_ids3_keymimeextracted_text_keysizePre-signed uploads; text extraction runs async before the file is usable as context.
user_memoryPostgres / KVuser_idmemory_idtextembeddingcreated_atOptional long-term memory facts, retrieved and added to context; user can view and delete them.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Send a message and stream the answer
- 1The app posts the message with parent_message_id and model. The Chat API checks the plan limit in Redis and writes the user message to Cassandra.
- 2It creates a generation_id, writes an empty assistant message with status=streaming, and returns 202.
- 3The app opens SSE to /generations/{id}/stream on any edge pod; the pod reads from the Redis stream (blocking XREAD).
- 4The context builder walks the branch from the leaf to the root, adds the system prompt and memory, and trims or summarises old turns to fit the token budget (e.g. 16k for free, 128k for paid).
- 5A fast input classifier runs in parallel with queueing; if it blocks the message, the generation is cancelled with a policy message.
- 6The scheduler places the request in the queue for that model and tier and sends it to a GPU server with free KV-cache capacity, preferring one that already holds this conversation's prefix.
- 7The GPU server prefills the prompt, then decodes tokens inside a continuous batch with other requests and appends each few tokens to the Redis stream.
- 8The edge pod forwards entries as SSE events with sequence ids; output moderation checks text every ~200 tokens and can stop the stream.
- 9On completion the persister writes the final content and token counts to Cassandra and sets status=done; the Redis stream expires after 10 minutes.
Phone switches from Wi-Fi to mobile data mid-answer
- 1The SSE connection drops at token 180. Generation on the GPU continues; it does not know or care about the client.
- 2The app reconnects to a different edge pod with Last-Event-ID: 180.
- 3The pod reads the Redis stream from entry 181 and continues; the user sees no gap and no regeneration.
- 4If the user never returns, generation still finishes and is saved, so the answer is there when they reopen the app.
Regenerate an answer
- 1The user taps regenerate on the assistant message M5 whose parent is user message M4.
- 2The Chat API creates a new assistant message M5b with parent M4, a sibling of M5, and starts a new generation.
- 3The conversation's current_leaf_id moves to M5b; the UI shows '2 / 2' to switch between branches.
- 4Nothing is overwritten, so history and shared links stay consistent.
7 Deep dives
Inference serving: prefill, decode and batching
Generation has two phases. Prefill processes the whole prompt in parallel; it is compute-bound and sets time to first token. Decode produces one token at a time per request; it is memory-bandwidth-bound because every step reads the model weights and the request's KV cache (the attention keys and values of all previous tokens). Serving one request at a time wastes the GPU, so servers use continuous batching: new requests join the running batch at each decode step instead of waiting for the batch to finish (vLLM, TensorRT-LLM, SGLang do this). The limit is KV-cache memory: a long conversation can hold gigabytes, so paged KV cache (allocating it in blocks, like virtual memory) avoids fragmentation and lets more requests fit. Prefix caching reuses KV blocks for identical prefixes such as the shared system prompt and the earlier turns of the same conversation, which is why the scheduler should send a conversation back to the GPU that served its last turn. Large models are split across GPUs with tensor parallelism inside a node. Some fleets separate prefill and decode onto different GPU pools so long prompts do not stall streaming for everyone else. Capacity planning is in tokens per second per GPU at a target latency, not requests per second.
Admission control and degradation under GPU shortage
GPUs are the scarce, expensive resource, and you cannot autoscale them in seconds the way you scale web pods. The scheduler keeps a queue per (model, tier) and admits work to a GPU only when it has KV-cache room and the server's tokens-per-second per stream stays above the target (say 30 tok/s). Under pressure, apply policy in order: paid users first; free users wait in a visible queue ('high demand, your answer will start shortly'); then free users are routed to a smaller model; then free sign-ups or new conversations get rate-limited harder. Plan limits ('80 large-model messages per 3 hours') are themselves a capacity tool, enforced with sliding window counters before anything is queued. Set a maximum queue time (e.g. 20 s); requests older than that are rejected rather than served late, because a user who already left wastes GPU time. Cancel propagation matters: when a user presses stop or closes the app before the first token, remove the request from the queue or batch immediately. Track queue time, TTFT and tokens/s per tier as the real SLOs, and scale the fleet on forecasts (daily curve, exam season, launches) plus reserved capacity across regions.
Decoupling generation from the connection
If tokens flow directly from the GPU server to the user's TCP connection, every network blip kills the answer and costs a full regeneration, and every edge pod must be sticky to a GPU. Instead, the GPU server writes tokens to a per-generation Redis Stream (append-only, ordered, with entry ids), and any edge pod can read it with XREAD from a sequence number. SSE fits well: one-way server-to-client, works over HTTP/2 through proxies, and has Last-Event-ID built in for resume. The edge pods are tuned for long-lived connections: async I/O, heartbeats every 15 s so mobile carriers and proxies do not drop idle streams, proxy buffering disabled, and a bounded buffer per connection so a slow client cannot grow memory. At ~300k concurrent streams you need hundreds of edge pods and must drain them gracefully on deploy, which is easy here because clients simply reconnect and resume. Write coalescing helps cost: append every 3-5 tokens or every 50 ms rather than every token, reducing Redis operations by ~4× with no visible difference. The final message is persisted once at the end, not per token.
Context assembly and conversation storage
Models are stateless; every turn resends the conversation. The context builder walks the selected branch from the leaf back to the root, then fits it into a token budget: system prompt, user memory facts, attachment text, then turns from newest to oldest. When history is too long, keep the first message (it often states the task) and the recent turns, and replace the middle with a running summary generated asynchronously by a small model after each turn. Count tokens with the model's tokenizer; Indian-language text can use 3-5× more tokens than English for the same meaning, which matters for both limits and cost. Storage is simple key-value at huge scale: messages partitioned by conversation_id in Cassandra or DynamoDB, conversations partitioned by user_id sorted by updated_at for the sidebar. The tree model (parent_id per message) makes edit and regenerate cheap and keeps shared links stable. Writes per message are few (user message, assistant placeholder, final assistant message), so 500M messages/day is about 17k writes/s, comfortably handled by a modest Cassandra cluster. Deleting a conversation must also delete attachments, memory derived from it and cached summaries, which privacy laws such as India's DPDP Act expect.
8 Trade-offs
SSE vs WebSocket
SSE is one-way, plain HTTP, proxy-friendly and resumable with Last-Event-ID, which matches token streaming. WebSocket is needed for two-way low-latency traffic such as voice mode. Use SSE for text chat and add WebSocket or WebRTC only for voice.
Own GPUs vs calling a model API
Calling a provider API removes the whole inference plane from your design and is right for most companies. Running your own fleet only pays off at very large, steady volume or when you own the model, and it brings GPU procurement, scheduling and utilisation problems. The interview often asks for the own-fleet version to test that knowledge.
Cassandra vs Postgres for messages
Postgres is fine up to a few thousand writes per second with sharding by user. At ~17k writes/s and petabyte-scale history, a wide-column store partitioned by conversation gives linear scaling and simple access patterns; you lose ad-hoc queries, which go to the analytics pipeline instead.
9 Bottlenecks
GPU capacity at a launch or exam-result spike
Traffic can double in an hour. Keep 20-30% headroom, have pre-approved degradation steps (shorter max output for free tier, smaller model, waiting room), and borrow capacity from batch or evaluation jobs that can be pre-empted.
Hot long conversations
Power users with 200-turn chats send huge prompts every turn. Summarise aggressively, rely on prefix caching with conversation affinity, and cap context per plan.
10 Failure handling
GPU server dies mid-generation
The scheduler sees the lost heartbeat, marks the generation failed at its last sequence, and either retries on another server from the start (sending a 'restart' event so the client clears partial text) or ends with an error and a regenerate button. Partial text already persisted in the Redis stream is not lost.
Redis stream node fails
In-flight generations on that shard lose resume ability; clients still connected keep receiving after replica promotion, others see an error and can regenerate. Final messages are persisted to Cassandra, so history is safe.
Message store slow
Sending still works if the user message write is acknowledged by a quorum; reads fall back to the client's local cache of recent conversations. Never block streaming on the history write path.
11 What impresses
- Sizes the GPU fleet from output tokens per second with stated assumptions
- Explains prefill vs decode and why KV-cache memory limits batch size
- Makes streams resumable instead of tying generation to a TCP connection
- Has an explicit degradation ladder for GPU shortage by tier
- Uses a message tree so regenerate and edit never overwrite history
- Notes that Indian languages cost more tokens per sentence
12 Mistakes
- Sizing GPUs by requests per second instead of tokens per second
- Holding a synchronous HTTP request from client to GPU for the whole generation
- Writing every token to the database
- Ignoring what happens when the user closes the app mid-answer
- Treating the conversation as a flat list, so regenerate destroys the previous answer
- No plan for when demand exceeds GPU capacity
13 Follow-ups
Lessons this design uses
Practice questions
- Users say your support chatbot feels slow. Answers take about 6 seconds and appear all at once. The model is fine. What do you change?
- In your ChatGPT-like product, long conversations get slower and more expensive with every message, and eventually fail with context-length errors. How do you manage conversation context?
- Your AI assistant feature takes 8-15 seconds to generate an answer. Users think the app is frozen. How would you stream the response, and what can go wrong?
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.