How would you design a real-time chat application in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
The message lifecycle: from keystroke to delivery confirmation
Every outgoing message passes through four states: sending, sent, delivered, and failed. When the user hits send, the message object is appended to the local messages array immediately with status: 'sending' and a client-generated ID. This optimistic append is what makes the chat feel instant.
The message is then sent over the WebSocket. When the server echoes back a message:confirmed event carrying the same ID, the code maps over the messages array and flips that entry's status to delivered. If the WebSocket is not connected at send time, the message goes into a pendingMessages array and is sent when onopen fires.
The crucial invariant is that the client-generated ID must be stable across the retry if you regenerated the ID on retry, the confirmed event would not find its matching message and the status would never update.
Exponential backoff reconnection without infinite loops
The scheduleReconnect function is the most fragile piece of the architecture. It must satisfy three constraints simultaneously: it must not reconnect if a connection is already open or connecting, it must not schedule a second reconnection if one is already pending, and it must not keep retrying indefinitely at a high rate during a sustained outage.
The demo uses Math.min(1000 * Math.pow(2, retryCount), 10000) to cap the delay at 10 seconds. The reconnectTimeoutRef prevents double-scheduling before setting a new timeout, the function checks whether one is already running and returns early if so.
The wsRef.current === ws check in onclose prevents a stale WebSocket instance (one that was replaced by a newer connection attempt) from triggering yet another reconnection cycle. Without this check, closing and reopening a connection rapidly can spawn an unbounded chain of reconnect calls.
Message grouping and the scroll-to-latest decision
The groupedMessages computed value uses useMemo to avoid regrouping on every render. It iterates the messages array linearly, comparing each message's author and timestamp to the previous one.
If the same author sent the previous message within 60 seconds, the new message is appended to the current group's messages array rather than starting a new group. This is an O(n) scan on every relevant state change, which is acceptable for chat histories up to a few thousand messages.
The auto-scroll decision is handled by calling scrollToBottom on two specific events: when the user sends a message and when an incoming message arrives. A production implementation would only auto-scroll on incoming messages if the user is already near the bottom Slack's behavior to avoid interrupting reading.
The demo omits that check for clarity, but an interviewer who asks "what would you change in production?" should get exactly this answer.
💡 Key Code Explained
const connect = useCallback(() => {
if (wsRef.current?.readyState === 1) return;
setReconnecting(true);
const ws = new MockWebSocket('wss://chat.example.com');
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
setReconnecting(false);
setRetryCount(0);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
pendingMessages.forEach((msg) => {
ws.send(JSON.stringify({ type: 'message', ...msg }));
});
setPendingMessages([]);
};
ws.onclose = () => {
setConnected(false);
setReconnecting(false);
if (wsRef.current === ws) {
scheduleReconnect();
}
};
}, [pendingMessages, users, scrollToBottom]);
The wsRef.current === ws guard in onclose is what separates a correct reconnection implementation from one that leaks connections. When connect is called again while a previous connection is closing, wsRef.current gets updated to the new WebSocket before the old one fires its onclose.
Without this identity check, the old socket's onclose would also trigger scheduleReconnect, creating two parallel reconnection chains. The pendingMessages.forEach in onopen is intentionally ordered before setPendingMessages([]) clearing the array first and then iterating it would send nothing.
const handleInputChange = useCallback(
(e) => {
const value = e.target.value;
setInputText(value);
if (value.trim() && !isTyping && wsRef.current?.readyState === 1) {
setIsTyping(true);
wsRef.current.send(
JSON.stringify({
type: 'typing',
userId: CURRENT_USER.id,
isTyping: true,
}),
);
}
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
if (isTyping && wsRef.current?.readyState === 1) {
setIsTyping(false);
wsRef.current.send(
JSON.stringify({
type: 'typing',
userId: CURRENT_USER.id,
isTyping: false,
}),
);
}
}, 2000);
},
[isTyping],
);
This function sends the typing: true event only on the first keystroke of a typing session, not on every character. The !isTyping guard prevents flooding the server with one event per key press.
The clearTimeout + setTimeout pattern restarts a 2-second timer on every keystroke; only when the user pauses for 2 seconds does the typing: false event get sent.
A common mistake is wrapping the entire block in a debounce utility that would delay the initial typing: true event as well, meaning the indicator appears late. The correct pattern is to fire immediately on first keystroke, and debounce only the "stop" event.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| Client-generated message IDs (chosen) | Enables optimistic UI without a server round-trip for the ID | ID collisions possible without a robust generator; IDs must be globally unique if messages are persisted |
| Server-assigned IDs (wait for server before appending) | No collision risk; IDs are stable | Visible latency before message appears; message flickers into view after network round-trip |
| Exponential backoff reconnection (chosen) | Prevents thundering herd under sustained outage; backs off gracefully | State complexity: must track retry count, timeout refs, and connection identity |
| Immediate reconnect on close | Simpler code | Hammers the server during outages; can exhaust file descriptors on the server side |
| Window scroll events for scroll detection | Universal browser support | Fires on every pixel scroll; must be throttled; couples logic to window rather than container |
🎯 What Interviewers Actually Check
- Knows that
wsRef.current === wsinoncloseis necessary to prevent stale socket instances from triggering reconnection after the reference has been replaced - Can articulate that
pendingMessagesmust be sent inonopenbefore being cleared not after and that the array must use the closed-over snapshot, not the current state - Distinguishes between firing
typing: trueimmediately (on first keystroke) versus debouncing it understands why debouncing the start event creates a bad UX - Explains why
useMemoongroupedMessagesis important and what the re-computation cost would be without it on a 5000-message history - Mentions that auto-scroll should only trigger when the user is within a threshold of the bottom, not unconditionally on every incoming message
❓ Follow-Up Questions
- The
connectfunction closes overpendingMessagesfrom the closure scope. If a new message is added topendingMessagesafterconnectis called but beforeonopenfires, will it be sent on reconnect? How would you fix this? - The demo uses
Date.now()for message timestamps. In a multi-timezone production app, what happens when two users in different timezones send messages simultaneously and the server uses client-reported timestamps for ordering? - How would you write a unit test for the reconnection backoff logic without real timers? What would you mock and what would you assert?
- The current implementation loads no message history it starts empty. How would you merge a paginated history loaded from REST with live WebSocket messages arriving concurrently, without duplicates?
- Your team is seeing increased memory usage in long-running chat sessions. The messages array is unbounded. What is the safest strategy for trimming old messages without breaking scroll position or the grouping calculation?
🎮 Live Demo: Real-time Chat Application
📝 Summary
A production chat UI is fundamentally a state synchronization problem: the local messages array must stay consistent with the server's authoritative record despite network interruptions, concurrent writes, and out-of-order delivery.
Optimistic rendering solves the latency problem by appending messages immediately with a client-generated ID, then updating the status when the server acknowledges; the same ID is what makes the confirmation round-trip work.
Exponential backoff reconnection prevents a global outage from turning into a thundering herd, but it introduces connection identity complexity you must distinguish which socket instance's onclose event is the authoritative one.
Typing indicators are a canonical example of "fire immediately, debounce only the stop event" and understanding that distinction separates a working implementation from one that feels sluggish. The grouping optimisation via useMemo is the kind of detail that only matters at scale but signals architectural awareness to an interviewer.
Why use WebSockets instead of polling?
WebSockets provide bidirectional real-time communication with lower latency and fewer network round-trips.
How do you handle offline users?
Queue messages locally, retry on reconnect, and use presence sync to update UI states.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement