Topic 15.2
Frontend System Design: Design a Chat UI
In one line
A frontend system design round asks you to design a product, here a chat UI for an AI assistant, and to defend your choices. This topic walks the whole round in eight steps: requirements, protocol, state, rendering, failure, scale, safety and accessibility, with the decisions that can be shown running live.
Think of it like this
An architect meeting a client. She does not start by drawing walls. She asks how many people live there, who is old, who works from home, what the budget is. Only then does she draw. A design round is the same: the questions you ask in the first five minutes are half the answer.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Requirement
- Something the product must do (functional) or how well it must do it (non-functional: speed, safety, accessibility).
- Time to first token
- How long after sending until the first word appears. The number users feel most.
- Transport
- The way the page and the server exchange data: a streamed HTTP response, a WebSocket, polling.
- Source of truth
- The one place where the real value lives. Other copies are caches.
- Virtualisation
- Drawing only the rows currently on screen, so a list of thousands stays fast.
- Memoisation
- Skipping the work for a component whose inputs did not change (
React.memo). - Context budget
- The number of tokens you allow yourself to send as history on one request.
- Degraded mode
- A reduced but working version of the feature when something is down.
- Live region
- A part of the page that a screen reader announces when its content changes.
Step by step
01Step 1: requirements and numbers
Write two short lists. Functional: type a question and see a streamed answer; stop it; retry or regenerate; keep a history of the conversation; show answers with formatting and code; show an order card and ask before cancelling an order. Non-functional: first words in about a second on a normal connection; works on a phone; a conversation of 200 messages stays smooth; no key in the browser; accessible with a keyboard and a screen reader; model output is treated as untrusted; daily cost is bounded.
Then say what is out of scope for version one: voice, branching, attachments, several agents. Tell the interviewer you will return to them at the end.
02Step 2: the protocol
Choose: the page sends a POST with the conversation to your route, and the route answers with a streamed text/event-stream response. The page reads it with fetch (Topics 2.2 to 2.5, or the AI SDK's transport in Topic 5.3). Stop is abort() on that request (Topic 3.1).
Alternatives and why you do not choose them: polling adds delay and wastes requests; a WebSocket is two-way and keeps a connection open per user, which costs more to run and needs its own reconnect and auth logic. A chat answer is one request and one long reply, which an HTTP stream already does well. A WebSocket becomes reasonable when the server must push things unrelated to a request (a long-running agent that finishes while you are away, Topic 14.2).
03Step 3: state, and where each piece lives
Split state into three homes. On the screen: the text in the box, whether the answer panel is scrolled to the bottom, which approval card is open. In the browser: the draft, a cached copy of recent conversations so the page opens instantly. On the server: the conversations that matter (account-owned, searchable, shared between devices), the usage counters and the audit log.
Inside the page, keep one reducer with a list of messages (stable ids, a role, parts, a status) and one status for the whole request. Say the events: send, token, done, error, stop. The reducer ignores events for an old request id (Topics 4.1, 4.2 and 4.7). Persist after each completed turn, not after each token.
04Step 4: rendering a long, growing conversation
Three problems and their answers. Re-rendering every message on every token: give each message a stable id key and wrap the row in memo, so only the streaming message changes. Parsing Markdown again for the whole answer on every token: split the answer into blocks and parse only the last, unfinished block (Topic 6.5). Thousands of rows: virtualise, drawing only the rows near the screen (Topic 6.5), and keep the scroll position when new rows arrive above or below.
The first live example counts how many row components render while 13 tokens stream into a list of 50 messages. Read the three numbers: without memo every row renders on every token; with memo almost none do; and memo with a fresh copy of each message on every update is no better than no memo at all, a mistake that is easy to make.
05Step 5: failure, one table
List every failure and give each its own message, its own retry rule and a decision about the partial answer. The second live example is that table as one function. It takes what happened (a status, offline, a broken stream, a stop, a content filter, a length cut) and returns the sentence for a person, whether a retry makes sense, and whether to keep the partial text. Raw provider errors never reach the screen (Topics 1.3 and 3.3).
06Step 6: scale
Long chats: every request resends the history, so cost and delay grow with every turn. Set a context budget, keep the newest turns that fit, tell the user when older turns are no longer sent, and refuse a single message that alone is too big instead of cutting it (Topic 4.3). The third live example does this and prints what happens at each edge.
Many users: the server counts usage per user with an atomic check, answers 429 with retry-after, and the page shows a countdown (Topics 3.6 and 13.2). Many tabs: a browser storage event keeps drafts in sync, and the server is the source of truth for the history. Slow starts and breaks: a watchdog for a stalled stream (Topic 3.5) and a resume for long jobs (Topic 14.2).
07Steps 7 and 8: safety and accessibility
Safety: output is untrusted, rendered as React elements and never as HTML; links and images follow an allow-list; tools that change things need an approval card built from server data, and every tool call is checked against the signed-in user on the server (Topics 6.3, 8.6, 9.5).
Accessibility: one polite live region that announces when an answer starts and when it is complete, not every token (Topic 10.6); focus moves to the approval card when it appears and back to the box afterwards; Stop and Copy work with the keyboard; motion respects prefers-reduced-motion; code blocks keep sufficient contrast. Finish by saying what you would measure: time to first token, stop rate, error rate, feedback (Topic 13.5).
Try it yourself
- 1
Count the cost of a longer answer
In the first example add ten more words to the
wordssentence and press Run. The plain rows grow by 51 renders for each extra word. The memo rows grow by one.This is the number to give an interviewer: with 50 messages and a 13-token answer a plain list does 663 row renders, a memoised one 63.
- 2
Add a case to the failure table
In the second example add
{ kind: "stalled" }to the cases and acase "stalled"that says "Mitra is taking longer than usual. You can wait or try again." with retry"yes". Run it. What did the unknown kind say before you added the case? - 3
Change the budget
In the third example change the budget in the "10 turns, 200" line to
100000. Nothing is dropped and the note says all messages were sent. Then change it to20. The newest message alone does not fit, so you gettoo_longinstead of a cut message.
Code & diagrams
Number 3 is the trap: memo compares each prop by identity, and a copy of a message is a new object even when its text is the same. Keep finished messages as the same objects, and change only the streaming one.
Preview
Console
Nothing logged yet.Expected console output
1. plain rows 663 row renders for 13 tokens
2. memo rows 63 row renders for 13 tokens
3. memo, copies every update 663 row renders for 13 tokensThe last case is an unknown status. It falls to the generic message, and the provider's own text (which contained an internal id) is never shown.
Preview
Console
Nothing logged yet.Expected console output
http 400 | say: "Mitra could not read that. Please rephrase and try again." | retry: no
http 401 | say: "Please sign in again to keep chatting." | retry: after sign-in
http 429 | say: "Daily limit reached. Try again in 2 minutes." | retry: after 90 s
http 503 | say: "Mitra is having trouble right now. Please try again." | retry: yes, with backoff
offline | say: "You seem to be offline. Check your connection." | retry: when back online
stream | say: "The answer was cut off. Please try again." | retry: yes | keep the partial text, mark it cut off
stopped | say: "" | retry: Ask again or Continue | keep the partial text, mark it stopped
filter | say: "Mitra cannot help with that request." | retry: not as written
length | say: "The answer was shortened. Continue?" | retry: Continue | keep the text
http 418 | say: "Something went wrong. Please try again." | retry: yesA real app counts with its provider's token counter or a safe over-estimate (Topic 13.1). The rule that matters here: keep the newest turns that fit, tell the user, and refuse a single message that does not fit rather than send half of it.
Preview
Console
Nothing logged yet.Expected console output
empty chat { sent: 0, dropped: 0, used: 0, note: 'nothing to send' }
2 turns, 200 { sent: 4, dropped: 0, used: 90, note: 'all sent' }
10 turns, 200 { sent: 8, dropped: 12, used: 180, note: 'show: Earlier messages are no longer sent' }
10 turns, 60 { sent: 2, dropped: 18, used: 45, note: 'show: Earlier messages are no longer sent' }
one huge paste { error: 'too_long', needs: 504, budget: 200 }Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Take memo away
In the first example change stream("2. memo rows", MemoRow, false); to stream("2. memo rows", Row, false); and press Run.
Myth vs fact
Myth
A good design round answer is the one with the most technologies in it.
Fact
It is the one where each choice has a reason tied to a requirement and a named trade-off. Fewer, defended choices beat a long list.
Myth
You need a WebSocket for real-time text.
Fact
A streamed HTTP response delivers tokens in real time for a request and a reply. Use a WebSocket when the server must send things without a request.
Myth
Keep the whole conversation on the client; it is simpler.
Fact
It is simpler until the user opens a second device or loses the browser data. Keep a cache on the client, and the truth on the server (Topic 4.4).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Draw as you talk. Three boxes (browser, route, provider), one arrow for the stream, one dashed arrow back for the abort. Interviewers remember a diagram.
- ▸
When you are asked "what would you do with more time?", give two things with reasons: for example a resumable stream for long jobs and an evaluation set for the format of answers (Topics 14.2 and 13.7).
- ▸
Mention measurement before you are asked: time to first token, stop rate, error rate, thumbs down rate (Topic 13.5). A design that cannot be measured cannot be improved.
- ▸
Keep numbers consistent. If you said 200 messages in the requirements, use 200 when you talk about virtualisation.
Remember this
- 1
The problem this solves: "Design a chat UI for an AI assistant" is a huge, vague sentence. Candidates fail it by jumping into one detail (the Markdown library, the animation) and forgetting the rest. This topic gives you an order to follow, so you cover everything that matters and spend your time where the risk is.
- 2
The eight steps. (1) Requirements and numbers. (2) Protocol: how the page and the server talk. (3) State: what is stored, and where. (4) Rendering: how a long, growing conversation stays fast. (5) Failure: what each thing that can go wrong looks like. (6) Scale: long chats, many users, many tabs. (7) Safety. (8) Accessibility. You can say the list at the start. It shows the interviewer you have a plan.
- 3
Start with questions, and write the answers down. Who uses it, on what devices, in which languages? Is the answer text only, or cards and tools? Must the chat survive a refresh, or move between devices? How long are conversations? What is a good time to first token? What happens at the limit? Put the answers in a short list on the board. Later decisions point back to it ("because we said mobile").
- 4
Name the trade-off in every decision. "We use
fetchwith a streamed response, because the request needs a body and a header. A WebSocket would let the server push without a request, but it needs its own reconnect and auth, which this feature does not need." A decision without its reason is a fact. A decision with its reason is design. - 5
Show numbers, even rough ones. "A 4,000-token history on every request" or "200 messages in a long chat" turns an abstract worry into a problem you can solve. The live examples below measure two of these: how many components re-render per token, and how many messages fit a context budget.
- 6
Know what to leave out. A first version does not need branching, voice, or multi-agent displays. Say what you would cut and what you would add in a second version. It shows judgement, and it keeps you from running out of time with half a design.
Explain it without notes
Walk me through how you would design the frontend of a chat UI for an AI assistant.
Why a streamed HTTP response and not a WebSocket? When would you change your mind?
What state do you keep, where does each piece live, and what happens when the user opens a second tab?
A conversation reaches 5,000 messages and the page gets slow. What do you do?
List what can fail in this chat and what the user sees in each case.
How do you keep the history from growing without limit?
What are the safety and accessibility parts of your design?
Practice
You are asked: "Design an AI search box with suggestions that appear as you type." Name the first six questions you would ask and two decisions that differ from the chat design.
Take the failure table in the second example and add the case "approval expired": the user left the page for an hour and then pressed Yes. What should the message be and what must the server check?
Trade-offs
- ↔
A broad answer shows you can see the whole system; a deep answer on one part shows you can build. A good round does both: cover all eight steps briefly, then go deep where the interviewer pulls.
- ↔
Choosing the simplest transport and the simplest state that meets the requirements leaves room to grow, but you must say plainly what you would change when the requirements change.
Done when you can
I can run a design round in eight steps and say the list at the start.
I can ask requirements questions and write down numbers.
I can defend a streamed HTTP response and say when I would use a WebSocket.
I can say what state lives on the screen, in the browser and on the server.
I can explain how rows, Markdown and scroll stay fast in a long chat.
I can give each failure its own message and retry rule.
I can describe the safety and accessibility parts of the design.