"Design a chat app" is one of the most common system design prompts you'll get in an interview, and it's also a real thing you might build. The problem with most answers is they reach straight for buzzwords. Someone says "I'll use WebSockets and Kafka and Redis" before they've explained what those things do or why they're needed.
Let me walk through it the way I'd actually reason about it.
Start with what "real-time" means
Real-time here just means messages show up fast, ideally within a second or two, without the receiver refreshing anything. That's it. No magic.
The old way to fake this was polling: the client asks the server "anything new?" every few seconds. It works, but it's wasteful and laggy. You're either asking too often or finding out too late.
The better answer is a persistent connection. The client opens one connection to the server and keeps it open. When a new message arrives, the server pushes it down that connection immediately. WebSockets are the usual tool for this. Server-Sent Events also work if you only need the server to push to the client and not the other way around.
So the first real decision is: keep a connection open per user. That's the whole foundation.
The hard part is connections, not messages
Here's the thing people miss. Sending a message is easy. The hard part is knowing where the recipient is.
Picture two users, Ana and Ben. Ana sends Ben a message. Your server has to deliver it down Ben's open connection. But if you have more than one server (and you will, once you have real traffic), Ben's connection might be on server 3 while Ana's request landed on server 1. Server 1 has no idea where Ben is.
This is the core problem of chat at scale, and it has nothing to do with the chat part. You need a way to find which server holds a given user's connection.
The common pattern: maintain a registry that maps user_id to server_id. When Ben connects, you write "Ben is on server 3" somewhere both fast and shared. Redis is a reasonable choice because the lookup needs to be quick. When Ana's message comes in, server 1 checks the registry, sees Ben is on server 3, and forwards the message there. Server 3 pushes it to Ben.
If Ben is offline, there's no connection to push to. So you store the message and deliver it when he reconnects. Which brings us to storage.
Storing messages
You need a database for two reasons: offline delivery and history. People expect to scroll up and see old messages.
The access pattern is simple and predictable. You almost always read messages for one conversation, in order, most recent first. That shape matters more than the database brand. Whatever you pick, you want messages keyed by conversation and sorted by time, so fetching "the last 50 messages in this chat" is one cheap query.
A row per message works fine: message id, conversation id, sender id, body, timestamp. Index it by conversation id and timestamp. That's enough to power both the live feed and scrollback.
Ordering and the timestamp trap
Messages need to appear in a consistent order for everyone in the conversation. The naive move is to order by the sender's clock. Don't. Clocks on different devices disagree, sometimes by a lot, and a phone with a wrong clock will scramble the conversation.
Order by something the server controls. The simplest version: when a message reaches the server, the server assigns it a timestamp or a sequence number. Everyone orders by that. It's not perfectly fair to the millisecond, but it's consistent, and consistent beats fair here.
For group chats this matters more, because now several people are sending at once and you need one agreed order. A per-conversation sequence number, assigned server-side, handles it.
Delivery guarantees, plainly
You'll get asked "what if a message gets lost?" Be honest about the trade-off.
To avoid losing messages, write the message to the database before you tell the sender it was sent. If the push to the recipient fails, the message still survives and gets delivered on reconnect. The cost is that a message can be delivered twice if a retry fires after the first delivery actually worked. So give each message a unique id and let the client ignore duplicates. Deliver-at-least-once plus dedupe by id is a clean, common answer.
The "delivered" and "read" checkmarks are just more small messages flowing the other way. When Ben's client receives the message, it sends a tiny "delivered" event back. When he opens the chat, it sends "read." Same connection, same plumbing.
What to skip in an interview
You don't need Kafka to design a chat app, and reaching for it early usually signals you're pattern-matching instead of thinking. A message queue earns its place when you have heavy fan-out (large group chats, presence updates to thousands) or you want to decouple ingestion from delivery. Mention it as a scaling step, not the starting point.
Same with presence (the green "online" dot), push notifications, and end-to-end encryption. They're real features, but they're additions. Build the core first: connections, a registry to find users, server-assigned ordering, durable storage, and dedupe.
If you can explain those five pieces in plain language and say why each exists, you've designed a chat app. The buzzwords are optional. The reasoning isn't.
A good way to get comfortable with this is to talk through it out loud, like you would in a real interview, and notice where you start hand-waving. That's where the real understanding is missing, and it's worth practicing until the explanation holds together without the jargon propping it up.