Design problems
AI-era systemsHardSDE-3 40 min

Design a ChatGPT-like chat service

Asked at
GoogleMetaMicrosoftNVIDIAOpenAI

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

SSE streaming with resumable sequence idsgeneration decoupled from connection via Redis Streamsprefill vs decode, continuous batching, paged KV cacheprefix caching with conversation affinityGPU capacity in tokens per secondadmission control and queueing by plan tiercontext window budgeting and summarisationmessage tree for edit and regenerateCassandra partitioned by conversation and userinput and streamed output moderationcancel propagation to free GPU slots

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

Generation rate~5,800/s avg, ~20k/s peak50M DAU × 10 messages/day = 500M/day ÷ 86,400 ≈ 5,800/s; evening peak in IST plus US daytime ≈ 3.5×
Output tokens~2.3M tokens/s avg, ~8M/s peak500M × ~400 output tokens = 200B tokens/day ÷ 86,400 ≈ 2.3M/s
Input (prefill) tokens~23M tokens/s avgEach turn resends history + system prompt: ~4,000 tokens × 5,800/s; prefix caching avoids recomputing most of it
GPU fleet~1,000 GPUs avg, ~3,500 at peakAssume ~2,500 output tokens/s per GPU for a mid-size model with continuous batching (varies 5-10× with model size and context length): 2.3M ÷ 2,500 ≈ 920; peak 8M ÷ 2,500 ≈ 3,200, plus headroom
Open streams~90k avg, ~300k peakLittle's Law: 5,800/s × ~15 s (queue + 400 tokens at 30-40 tok/s) ≈ 87k; peak 20k/s → 300k
Message storage~1 TB/day, ~1 PB over 3 years with replication500M × ~2 KB per message (both sides, metadata) = 1 TB/day; ×3 replicas, compressed ~2×
Read traffic~30k/sOpening the app loads the conversation list and one conversation; ~5 loads per DAU per day plus sync polls

3 API

EndpointWhat it does
POST/v1/conversations/{id}/messagesSend 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}/cancelStop 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}/shareCreate a read-only snapshot link.

4 Data model

conversationsCassandra / ScyllaDB (or DynamoDB)
user_id (partition key)updated_at (clustering, desc)conversation_idtitlemodelcurrent_leaf_id

One partition per user gives the sidebar list in one read. Title is generated asynchronously by a small model.

messagesCassandra / ScyllaDB
conversation_id (partition key)message_id (time-ordered, clustering)parent_idrolecontentstatus (streaming|done|stopped|error)modeltokenscreated_at

parent_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 status

Decouples generation from the client connection. Any edge pod can serve the stream; the final text is persisted to messages when done.

usage_limitsRedis
limit:{user_id}:{model}:{window} → count / tokens

Plan limits like 'large model: 80 messages per 3 hours'; sliding window counters.

attachmentsS3 + metadata in Postgres
file_iduser_ids3_keymimeextracted_text_keysize

Pre-signed uploads; text extraction runs async before the file is usable as context.

user_memoryPostgres / KV
user_idmemory_idtextembeddingcreated_at

Optional long-term memory facts, retrieved and added to context; user can view and delete them.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAPOST + SSEplan limitssave user msgbuild promptinput checkenqueue by tieradmit to batchtokens (XADD)read from seqfinal textattachmentsWeb + mobile appsCDN/LB + SSE edge podsContext builderChat APISafety classifiersGPU servers(vLLM-like)Inference schedulerMessage persisterRedis Streams: tokensS3: attachmentsRedis: limits,sessionsCassandra: messages
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Send a message and stream the answer

  1. 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.
  2. 2It creates a generation_id, writes an empty assistant message with status=streaming, and returns 202.
  3. 3The app opens SSE to /generations/{id}/stream on any edge pod; the pod reads from the Redis stream (blocking XREAD).
  4. 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).
  5. 5A fast input classifier runs in parallel with queueing; if it blocks the message, the generation is cancelled with a policy message.
  6. 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.
  7. 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.
  8. 8The edge pod forwards entries as SSE events with sequence ids; output moderation checks text every ~200 tokens and can stop the stream.
  9. 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

  1. 1The SSE connection drops at token 180. Generation on the GPU continues; it does not know or care about the client.
  2. 2The app reconnects to a different edge pod with Last-Event-ID: 180.
  3. 3The pod reads the Redis stream from entry 181 and continues; the user sees no gap and no regeneration.
  4. 4If the user never returns, generation still finishes and is saved, so the answer is there when they reopen the app.

Regenerate an answer

  1. 1The user taps regenerate on the assistant message M5 whose parent is user message M4.
  2. 2The Chat API creates a new assistant message M5b with parent M4, a sibling of M5, and starts a new generation.
  3. 3The conversation's current_leaf_id moves to M5b; the UI shows '2 / 2' to switch between branches.
  4. 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

Q1How would you add web browsing or code execution as tools?
Q2How do you implement long-term memory across conversations?
Q3How would you serve this from two regions, and where do conversations live?
Q4How do you roll out a new model version without hurting TTFT?
Q5How do you stop abuse of the free tier by scripts?
Q6How would voice mode change the architecture?

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