WebSockets — Integration, Reconnection, and Real-Time UX

Updated 4 interview angles 7 min read source
On this page13
  1. WebSocket vs SSE vs long polling — when each?
  2. What does the connection lifecycle look like in code?
  3. Why do you need reconnection logic and what’s the right pattern?
  4. How do you handle “I was disconnected — what did I miss?”
  5. How do you detect a “zombie” connection (TCP says open, peer is gone)?
  6. What’s backpressure on a WebSocket and how do you handle it?
  7. How do you do React + WebSocket integration cleanly?
  8. How do you authenticate a WebSocket?
  9. When is a WebSocket the wrong choice?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

WebSockets — Integration, Reconnection, and Real-Time UX

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 Streaming an LLM answer into a React UI).

WebSocket vs SSE vs long polling — when each?

Direction, reconnect Use for
WebSocket bidirectional; manual reconnect chat, presence, collab editing, multiplayer
Server-Sent Events server → client; automatic reconnect live feeds, notifications, dashboards
Long polling server → client, kind of; next poll legacy clients, low-volume push

Transport: a WebSocket upgrades an HTTP request to a dedicated TCP connection, SSE is one long-lived HTTP response, and long polling repeats ordinary requests.

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.

What does the connection lifecycle look like in code?

ts
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.

Why do you need reconnection logic and what’s the right pattern?

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.

ts
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); }
}

How do you handle “I was disconnected — what did I miss?”

This is the resync problem — the hardest part of any real-time UX. Three strategies:

  1. Replay log with cursor. Server keeps an append-only log of messages per room. Client sends { type: "resume", since: <lastMessageId> } on open; server streams everything after since. (Slack, Discord work this way.)
  2. Snapshot + diff. On reconnect, client fetches the current state (GET /room/123/state) and applies incoming deltas after. Simpler; doesn’t preserve message history.
  3. 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.

How do you detect a “zombie” connection (TCP says open, peer is gone)?

Application-level heartbeats (ping/pong). The browser’s built-in WS ping frames are not exposed to JS, so send your own:

ts
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" }));
  // no pong → kill it
  pongTimer = setTimeout(() => ws.close(), 10_000);
}, 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.

What’s backpressure on a WebSocket and how do you handle it?

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.
ts
function send(msg: unknown) {
  // 1MB buffered
  if (ws.bufferedAmount > 1_000_000) {
    // drop or warn user
    return;
  }
  ws.send(JSON.stringify(msg));
}

How do you do React + WebSocket integration cleanly?

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.

tsx
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.

How do you authenticate a WebSocket?

The browser does not let you set arbitrary headers on new WebSocket() (no Authorization header). Options:

  • Cookie auth — the upgrade request carries the cookies for the WebSocket’s host (subject to SameSite). WebSocket has no CORS and no withCredentials: any page can open a socket to your server with the user’s cookies, so the server must check the Origin header against an allowlist, or it is open to cross-site WebSocket hijacking (WebSocket Security).
  • Token in URL — wss://api/ws?token=.... The URL lands in server and proxy access logs, APM traces and error trackers (not in browser history or Referer, which a WebSocket does not produce). Use a short-lived, single-use ticket, never the session token.
  • Short-lived sub-protocol token — new WebSocket(url, [token]) — the Sec-WebSocket-Protocol header 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 JWT and the JOSE Family), short-lived + refresh is best.

When is a WebSocket the wrong choice?

  • 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 Worked Design — Chat & Presence for the backend.

Gotchas

  • 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.
  • readyState matters before send. Sending while CONNECTING (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.
  • onerror doesn’t give you details. It fires before onclose; 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.

Further reading

Interview angle 4

  • “WebSockets, SSE or polling?” - by direction and frequency. WebSockets when the client pushes too, and pushes often. SSE when it is server-to-client only, because it is plain HTTP with automatic reconnection built in. Polling when updates are infrequent enough that a socket is not worth operating.
  • “What does the browser do when a socket drops?” - nothing. Unlike EventSource, WebSocket has no reconnection at all, so exponential backoff with jitter is code you must write. Without jitter, a server restart brings every client back simultaneously and knocks it over again.
    • Follow-up: “Is reconnecting enough?” - no, and this is the part people miss. Reconnecting without a resync strategy silently loses everything sent while you were away. You need a replay log with a cursor, a snapshot plus diff, or a plain refetch on reconnect - but you need one of them.
  • “How do you detect a connection that is dead but not closed?” - an application-level heartbeat, because TCP will happily hold a zombie connection open across a network change. A ping every 30 seconds with a 10-second pong timeout is a typical setting.
  • “How do you integrate a socket with the rest of the app?” - open it once at the root, expose it through context, and have incoming messages write into the query cache. Components then read data the same way whether it arrived by fetch or by push, and nothing below the root knows the transport exists.