WebSockets — Integration, Reconnection, and Real-Time UX
TL;DR
WebSockets give you a bidirectional, persistent, full-duplex connection over TCP — the right choice for chat, presence, collaborative editing, live dashboards with frequent server pushes, and games. The senior answer is rarely “just open a socket” — it’s reconnection with backoff, resync after reconnect (because you missed messages), heartbeats (to detect dead connections that look open), backpressure, and a clear picture of when WebSockets are wrong (request/response on REST is simpler; SSE wins for server-to-client only — see 08_server_sent_events.md).
Interview Q&A
Q: WebSocket vs SSE vs long polling — when each?
A:
| Direction | Transport | Reconnect | Use for | |
|---|---|---|---|---|
| WebSocket | bidirectional | dedicated TCP (over HTTP upgrade) | manual | chat, presence, collab editing, multiplayer |
| Server-Sent Events | server → client only | HTTP/1.1 long-lived response | automatic (browser handles) | live feeds, notifications, server-driven dashboards |
| Long polling | server → client (kind of) | repeated HTTP requests | implicit (next poll) | legacy clients, low-volume push |
If the client doesn’t need to push frequently, SSE is simpler — auto-reconnect, native browser API, plays nice with HTTP/2 multiplexing, no special infrastructure. WebSockets are right when the client also pushes (chat input, cursor moves, presence) and latency matters.
Q: What does the connection lifecycle look like in code?
A:
const ws = new WebSocket("wss://example.com/chat");
ws.addEventListener("open", () => console.log("connected"));
ws.addEventListener("message", (e) => handle(JSON.parse(e.data)));
ws.addEventListener("error", (e) => console.warn("ws error", e));
ws.addEventListener("close", (e) => console.log("closed", e.code, e.reason));
ws.send(JSON.stringify({ type: "join", room: "lobby" }));
ws.close(1000, "user navigated");
The four events: open, message, error, close. error is usually followed by close — most reconnect logic listens to close.
Q: Why do you need reconnection logic and what’s the right pattern?
A: Connections die — network flakes, proxies time them out, mobile suspends them, deploys drop them. The browser does not auto-reconnect; you must.
Pattern: exponential backoff with jitter + cap + max attempts + tear-down on user navigation.
class ResilientSocket {
private ws?: WebSocket;
private attempt = 0;
private closedByUser = false;
constructor(private url: string, private onMessage: (msg: unknown) => void) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.attempt = 0; this.resync(); };
this.ws.onmessage = (e) => this.onMessage(JSON.parse(e.data));
this.ws.onclose = () => {
if (this.closedByUser) return;
const wait = Math.min(30_000, 250 * 2 ** this.attempt) * Math.random();
this.attempt++;
setTimeout(() => this.connect(), wait);
};
}
private resync() { /* see "resync" question below */ }
send(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(data));
else this.queue(data); // see backpressure
}
close() { this.closedByUser = true; this.ws?.close(1000); }
}
Q: How do you handle “I was disconnected — what did I miss?”
A: This is the resync problem — the hardest part of any real-time UX. Three strategies:
- Replay log with cursor. Server keeps an append-only log of messages per room. Client sends
{ type: "resume", since: <lastMessageId> }onopen; server streams everything aftersince. (Slack, Discord work this way.) - Snapshot + diff. On reconnect, client fetches the current state (
GET /room/123/state) and applies incoming deltas after. Simpler; doesn’t preserve message history. - Re-fetch from REST. For non-critical streams (presence, “X is typing”), just refetch state and accept the gap.
Without resync, every reconnect is a silent data loss. Interviewers ask about this specifically because the naive answer is “just reopen” — which is wrong.
Q: How do you detect a “zombie” connection (TCP says open, peer is gone)?
A: Application-level heartbeats (ping/pong). The browser’s built-in WS ping frames are not exposed to JS, so send your own:
let pongTimer: any;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "pong") { clearTimeout(pongTimer); return; }
handle(msg);
};
setInterval(() => {
ws.send(JSON.stringify({ type: "ping" }));
pongTimer = setTimeout(() => ws.close(), 10_000); // no pong → kill it
}, 30_000);
Without heartbeats, a dropped connection that didn’t close cleanly looks alive until the OS gives up (minutes). Heartbeat every 30s with a 10s pong timeout is typical.
Q: What’s backpressure on a WebSocket and how do you handle it?
A: If the network is slow and your code sends faster than the wire can drain, the browser buffers in ws.bufferedAmount. Unbounded buffering = memory blowup. Two patterns:
- Drop or coalesce updates when
bufferedAmount > THRESHOLD. Coalesce makes sense for cursor positions, presence, etc. (only the latest matters). - Queue with cap when each message matters (chat). Reject new sends once the cap hits.
function send(msg: unknown) {
if (ws.bufferedAmount > 1_000_000) { // 1MB buffered
// drop or warn user
return;
}
ws.send(JSON.stringify(msg));
}
Q: How do you do React + WebSocket integration cleanly?
A: Don’t open a WS in a useEffect of a leaf component — it’ll churn on remount. Open it at the app root (or a provider), expose via Context, and have components subscribe to message channels via a small pub/sub.
const SocketContext = createContext<ResilientSocket | null>(null);
export function SocketProvider({ url, children }: { url: string; children: ReactNode }) {
const [socket] = useState(() => new ResilientSocket(url, dispatch));
useEffect(() => () => socket.close(), [socket]);
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>;
}
export function useSocketChannel(channel: string, onMessage: (m: unknown) => void) {
const socket = useContext(SocketContext)!;
useEffect(() => {
return socket.subscribe(channel, onMessage);
}, [channel]);
}
Bonus: dispatch incoming messages into TanStack Query cache (setQueryData) so the same cache backs both polled REST data and WS pushes.
Q: How do you authenticate a WebSocket?
A: The browser does not let you set arbitrary headers on new WebSocket() (no Authorization header). Options:
- Cookie auth — the upgrade request carries cookies; works if the WS shares origin / has
withCredentials-compatible cookies. - Token in URL —
wss://api/ws?token=.... Token in URL is logged in proxies and leaks via referrer; treat as second-best. - Short-lived sub-protocol token —
new WebSocket(url, [token])— theSec-WebSocket-Protocolheader carries it. Less leaky than the URL. - Connect-then-authenticate — open the socket, first message is an auth frame. Server kicks unauthed connections.
For server-issued JWTs (see ../../backend/11_authentication/jwt/), short-lived + refresh is best.
Q: When is a WebSocket the wrong choice?
A:
- When the data flow is only server → client. SSE is simpler and auto-reconnects.
- When updates are infrequent (every few minutes). Polling is fine and cacheable.
- When you can’t operate the infrastructure — WS needs a load balancer that supports
Upgrade, sticky sessions or shared state for the connection registry, and visibility into hung connections. See ../../system_design/07_worked_designs/05_chat_presence.md for the backend.
Gotchas / edge cases
- No auto-reconnect. This is the bug 95% of WS tutorials skip.
- Heartbeats are mandatory in mobile/Wi-Fi networks. Without them, you discover the connection died only when the user tries to send.
readyStatematters beforesend. Sending whileCONNECTING(state 0) throws.- Close codes have meaning.
1000= normal,1006= abnormal (browser-set, no peer ack — common on drop),1011= server error. Logging the code is the first debugging step. onerrordoesn’t give you details. It fires beforeonclose; the close event has the useful info.- Frame size limits. Servers cap message size (commonly 1MB-16MB); large updates need chunking.
- HTTP/2 vs WS. WS over HTTP/2 (
RFC 8441) exists but adoption is patchy; most WS still runs over HTTP/1.1 upgrade. - Browser tab sleep — backgrounded tabs may have throttled timers, so heartbeats fire less often. Test reconnect after a long backgrounding.
What a senior is expected to say
- “WS for bidirectional, low-latency, frequent client pushes. SSE for server-to-client only — it’s simpler and auto-reconnects. Polling for low-frequency updates.”
- “Reconnection with exponential backoff + jitter is mandatory; the browser doesn’t do it. And reconnecting without a resync strategy silently drops data — replay log with cursor, snapshot + diff, or re-fetch.”
- “Application-level heartbeats catch zombie connections. 30s ping with a 10s pong timeout is typical.”
- “Open the socket once at the app root, expose it via context, and dispatch messages into TanStack Query cache so the rest of the app stays unaware of the transport.”
Cross-references
- SSE comparison: 08_server_sent_events.md
- Chat / presence design (server side): ../../system_design/07_worked_designs/05_chat_presence.md
- Backend WS deep-dive: ../../backend/12_protocols/websockets/
- Real-time UX in collaborative editing: ../14_frontend_system_design/
Further reading
- MDN — WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
- WebSocket close codes: https://www.iana.org/assignments/websocket/websocket.xhtml
- “WebSockets at Slack” engineering posts (resync architecture)