System Design β‘ Recently updatedUpdated on Jun 30, 2026 β this content may still be evolving.
Design a Chat App (Student-Level Walkthrough)
A complete interview-style answer for a real-time chat application using WebSockets. Practice with a whiteboard.
Step 1: Clarify Requirements (2β3 min)
| Question | Assumed answer |
|---|---|
| 1-on-1 or group chat? | Both (start with 1-on-1, mention group extension) |
| Real-time delivery? | Yes, messages appear within 1 second |
| Message history? | Yes, load last 50 messages on open, paginate older |
| Online presence? | Show online/offline status |
| Read receipts? | Out of scope for MVP |
| Scale? | 50K DAU, 5K concurrent WebSocket connections |
Functional requirements:
- User registration and login
- Start 1-on-1 conversation with another user
- Send and receive messages in real time
- View message history when opening a chat
- See if the other user is online
Non-functional requirements:
- Messages must be persisted (reload shouldnβt lose history)
- Messages delivered in order within a conversation
- System handles reconnect gracefully
Step 2: Rough Estimates (2 min)
| Metric | Estimate |
|---|---|
| 50K DAU | ~5K concurrent at peak (10% online) |
| Messages per day | 50K users Γ 30 messages = 1.5M messages/day |
| Avg message size | ~200 bytes text |
| Storage per year | 1.5M Γ 200B Γ 365 β 110 GB (very manageable) |
| WebSocket connections | 5K concurrent |
Step 3: High-Level Architecture
ββββββββββββ WebSocket + REST βββββββββββββββββββ
β React β ββββββββββββββββββββΊβ Chat Server β
β Client β β (Node.js + β
ββββββββββββ β Socket.io) β
ββββββββββ¬βββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
βΌ βΌ βΌ
ββββββββββββ ββββββββββββ ββββββββββββ
β Redis β β Postgres β β Redis β
β (pub/sub)β β (DB) β β(presence)β
ββββββββββββ ββββββββββββ ββββββββββββ
Why two Redis uses?
- Pub/Sub: Broadcast messages across multiple chat server instances
- Presence: Store online user IDs with TTL heartbeat
Why REST + WebSocket?
- REST: Login, fetch conversation list, paginate message history
- WebSocket: Real-time send/receive, typing indicators, presence
Step 4: Database Schema
users (
id UUID PK,
username VARCHAR(50) UNIQUE,
email VARCHAR(255) UNIQUE,
password_hash VARCHAR(255),
created_at TIMESTAMPTZ
)
conversations (
id UUID PK,
created_at TIMESTAMPTZ
)
conversation_members (
conversation_id UUID FK β conversations,
user_id UUID FK β users,
joined_at TIMESTAMPTZ,
PRIMARY KEY (conversation_id, user_id)
)
messages (
id UUID PK,
conversation_id UUID FK β conversations,
sender_id UUID FK β users,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
-- INDEX: (conversation_id, created_at DESC) for history pagination
Finding or creating a 1-on-1 conversation:
-- Check if conversation exists between two users
SELECT c.id FROM conversations c
JOIN conversation_members cm1 ON c.id = cm1.conversation_id AND cm1.user_id = $userA
JOIN conversation_members cm2 ON c.id = cm2.conversation_id AND cm2.user_id = $userB;
-- If none, create conversation + two member rows
Step 5: API Endpoints (REST)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/login | Login |
| GET | /api/v1/conversations | List userβs conversations (with last message preview) |
| POST | /api/v1/conversations | Create/get 1-on-1 conversation { userId } |
| GET | /api/v1/conversations/:id/messages?cursor=&limit=50 | Message history |
Step 6: WebSocket Protocol
Connection Flow
1. Client logs in via REST β gets session cookie / JWT
2. Client opens WebSocket: wss://api.example.com/ws?token=...
3. Server validates token on handshake
4. Server marks user online in Redis: SET online:userId 1 EX 60
5. Client sends heartbeat every 30s β refreshes TTL
6. On disconnect β remove from online set, broadcast offline
Message Events
// Client β Server: send message
{
"type": "message:send",
"payload": {
"conversationId": "uuid",
"body": "Hey, how are you?"
}
}
// Server β Client: new message (to all conversation members)
{
"type": "message:new",
"payload": {
"id": "uuid",
"conversationId": "uuid",
"senderId": "uuid",
"body": "Hey, how are you?",
"createdAt": "2025-06-30T10:00:00Z"
}
}
// Server β Client: user online/offline
{
"type": "presence:update",
"payload": { "userId": "uuid", "status": "online" }
}
Server-Side Message Handler
1. Receive message:send from client
2. Validate: is sender a member of this conversation?
3. Insert message into Postgres
4. Publish to Redis channel: conversation:{conversationId}
5. All server instances subscribed to that channel β push to connected clients
6. Send ack to sender with message id (confirms delivery to server)
Step 7: Deep Dive β Scaling WebSockets
Problem: Multiple Server Instances
User A connects to Server-1. User B connects to Server-2. How does Aβs message reach B?
Solution: Redis Pub/Sub
User A β Server-1 β INSERT message β PUBLISH conversation:123
β
Server-1 βββββββββββββ€
Server-2 βββββββββββββ
β
User B receives message
// On message send (Server-1)
await db.insertMessage({ conversationId, senderId, body });
await redis.publish(`conversation:${conversationId}`, JSON.stringify(message));
// On server startup (all instances)
redis.subscribe(`conversation:*`, (channel, data) => {
const message = JSON.parse(data);
const members = getConnectedSocketsForConversation(message.conversationId);
members.forEach(socket => socket.emit('message:new', message));
});
Load Balancer Consideration
- WebSocket connections are long-lived
- Use sticky sessions (IP hash) so reconnect goes to same server β OR β
- Use Redis pub/sub so any server can handle any connection (preferred)
Reconnection Handling
// Client-side (Socket.io handles most of this)
const socket = io('wss://api.example.com', {
auth: { token },
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 10,
});
socket.on('connect', () => {
// Rejoin conversation rooms
activeConversations.forEach(id => socket.emit('conversation:join', { id }));
// Fetch messages missed while offline via REST
syncMissedMessages();
});
Step 8: Presence System
// On connect
await redis.setex(`online:${userId}`, 60, '1');
broadcastToFriends(userId, { type: 'presence:update', status: 'online' });
// Heartbeat every 30 seconds
setInterval(() => redis.setex(`online:${userId}`, 60, '1'), 30_000);
// On disconnect
await redis.del(`online:${userId}`);
broadcastToFriends(userId, { type: 'presence:update', status: 'offline' });
// Check if user is online
const isOnline = await redis.exists(`online:${userId}`);
Tradeoff: 60-second TTL means βofflineβ detection has up to 60s delay. Acceptable for student apps.
Step 9: Frontend Architecture
features/chat/
βββ ChatPage.tsx # Main layout: sidebar + active chat
βββ ConversationList.tsx # List of conversations
βββ MessageList.tsx # Virtualized message list
βββ MessageInput.tsx # Send box
βββ useChatSocket.ts # WebSocket connection hook
βββ useMessages.ts # React Query for history
βββ chatStore.ts # Zustand: active conversation, typing state
State split:
- React Query: Message history (server state), conversation list
- Zustand/local state: Real-time messages not yet in cache, typing indicators
- WebSocket hook: Connection management, event routing
Optimistic UI for sending:
- User hits send β immediately show message in UI (gray/pending)
- Server acks with real ID β update message status
- On error β show retry button, remove optimistic message
Step 10: Tradeoffs Summary
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Transport | WebSocket | Bidirectional, low latency | SSE (serverβclient only), long polling (simpler but wasteful) |
| Message store | PostgreSQL | Durable, queryable history | Cassandra at massive scale |
| Multi-server sync | Redis Pub/Sub | Simple, fast fan-out | Dedicated message broker (Kafka β overkill here) |
| Presence | Redis TTL keys | Fast, auto-expires | DB polling (too slow) |
| Ordering | DB timestamp + single writer per conversation | Good enough for 1-on-1 | Vector clocks (distributed systems) |
Step 11: Security
- Authenticate WebSocket handshake (reject unauthenticated connections)
- Verify sender is conversation member before accepting message
- Sanitize message body (prevent XSS if rendered as HTML)
- Rate limit: 30 messages/minute per user
- WSS (WebSocket Secure) in production β always TLS
- Donβt log message content in production logs (privacy)
Extending to Group Chat
conversationstable already supports N members viaconversation_members- Fan-out: publish to
conversation:{id}β all members on any server receive it - Group-specific: show member list, admin can add/remove members
- Scale concern: 500-member group β 500 socket pushes per message (batch or lazy load)
Whiteboard Diagram
[Client A]βββWSβββ[Server 1]βββ[Redis Pub/Sub]βββ[Server 2]βββWSβββ[Client B]
β β
βββββββββββββ[Postgres]βββββββββββββββββββββββββ
Common Follow-Up Questions
Q: What if a user is offline when a message is sent?
A: Message is persisted in DB. When user reconnects, REST API fetches messages since lastSeenMessageId.
Q: How do you guarantee message order?
A: created_at timestamp from DB (single source of truth). Client sorts by timestamp. For same-millisecond messages, use UUID as tiebreaker.
Q: End-to-end encryption? A: Out of scope for MVP. Would encrypt on client, server stores ciphertext only. Mention as future improvement.
Practice tip: Draw the WebSocket + Redis pub/sub flow β interviewers love seeing you handle the multi-server case.