WebSockets
A persistent, full-duplex connection over a single TCP socket upgraded from HTTP: both sides can send framed messages any time with minimal overhead — the right tool for chat, collaboration, live dashboards and gaming — plus the reconnection, heartbeat and horizontal-scaling concerns you must design for.
What a WebSocket is. A WebSocket is a persistent, full-duplex (bidirectional) connection between browser and server over a single TCP socket. Once established, either side can send a message at any moment with only a few bytes of framing overhead — no new HTTP request, no repeated headers, no polling. That low-latency, both-directions capability is what makes it the right choice for chat, real-time collaboration (cursors, docs), live dashboards, trading, and multiplayer games.
The upgrade handshake. A WebSocket connection starts as an ordinary HTTP request. The client sends GET with Upgrade: websocket, Connection: Upgrade, and a random Sec-WebSocket-Key. If the server agrees, it replies HTTP 101 Switching Protocols with a Sec-WebSocket-Accept header (a hash of the key). After that single handshake the same TCP connection is 'upgraded' and speaks the WebSocket wire protocol instead of HTTP. The URL scheme is ws:// (or wss:// over TLS — always use wss in production).
Frames, not requests. After the handshake, data travels in lightweight frames carrying either text (UTF-8, typically JSON) or binary (ArrayBuffer/Blob) payloads. There are also control frames: ping/pong for keepalive and close for a clean shutdown. Framing overhead is a couple of bytes versus the hundreds of bytes of HTTP headers per message — which is why WebSockets crush polling for high-frequency updates.
The native browser API. The built-in WebSocket object is event-driven: onopen (connected, safe to send), onmessage (a frame arrived — inspect event.data), onerror, and onclose (with a code and reason). You send with socket.send(...) and close with socket.close(code, reason). readyState (CONNECTING/OPEN/CLOSING/CLOSED) tells you whether it is safe to send; sending before OPEN throws.
The hard part: reconnection. A raw WebSocket does nothing on network failure — it just fires onclose. Production code must detect the close and reconnect with exponential backoff and jitter (so a server restart doesn't cause a thundering-herd reconnect storm), then re-authenticate and re-subscribe, and re-fetch state missed while disconnected. Never reconnect in a tight loop. This reconnection + resync logic is non-trivial, which is exactly why libraries exist.
Heartbeats and dead connections. TCP can go 'half-open' — the connection looks alive but packets no longer flow (laptop sleeps, NAT timeout, mobile handoff). You detect this with heartbeats: periodic ping/pong (or app-level messages) with a timeout; if a pong doesn't arrive in time, you consider the socket dead and reconnect. Servers also use ping/pong to reap zombie connections and free resources.
Authentication. WebSockets can't send custom headers from the browser (no Authorization header on the handshake in the native API), so you authenticate by: passing a token as a query param or cookie on the handshake, or sending an auth message as the first frame right after onopen and letting the server reject unauthenticated sockets. Tokens can expire mid-connection, so plan for re-auth or short-lived tokens with refresh.
Socket.IO — the batteries-included option. Socket.IO wraps WebSockets with the plumbing most apps need: automatic reconnection with backoff, transport fallback to HTTP long-polling when WebSockets are blocked, rooms (server-side groups for targeted broadcast), acknowledgements (callback on delivery), namespaces, and an event-based API (socket.on('name', ...), socket.emit('name', payload)). Note it is not raw WebSocket-compatible — a Socket.IO client must talk to a Socket.IO server. Native WebSocket is leaner; Socket.IO is faster to ship a robust app with.
Using it in React — clean up your listeners. Register handlers inside useEffect and always remove them in the cleanup (socket.off(...) for Socket.IO, or removeEventListener/nulling handlers for native). Because a module-level socket persists across renders, re-running the effect without cleanup stacks duplicate handlers, so one event fires your callback multiple times — duplicated state and repeated toasts. One shared connection per app (often via context) is better than one per component.
Scaling WebSockets horizontally. This is a favorite interview topic. Connections are stateful and sticky to one server, so with multiple instances a message published on server A must reach clients connected to server B. You solve it with a pub/sub backplane — Redis pub/sub, a message broker (Kafka/NATS), or Socket.IO's Redis adapter — so any node can broadcast to any client. You also need sticky sessions / consistent hashing at the load balancer (especially with Socket.IO's polling fallback), and you must plan for connection limits and graceful drain on deploy.
The three browser real-time technologies — choose deliberately. WebSockets are one of three, and picking correctly is the interview payoff:
| Technology | Direction | Connection | Auto-reconnect | Best For |
|---|---|---|---|---|
| WebSocket | Bidirectional | Persistent TCP (ws/wss) | No (DIY) | Chat, collaboration, live dashboards, gaming |
| SSE (EventSource) | Server -> Client only | Persistent HTTP | Yes (built-in) | Feeds, notifications, live logs, tickers, LLM token streams |
| Push Notifications | Server -> Device | Via service worker | N/A | Alerts when the app is closed/backgrounded |
When NOT to use WebSockets. If data flows only server-to-client, SSE is simpler (plain HTTP, auto-reconnect, passes proxies). If updates are infrequent, plain requests or polling are fine. WebSockets add operational cost — stateful connections, sticky sessions, a scaling backplane, reconnection logic — so reach for them specifically when you need frequent, low-latency, client-initiated traffic too.
The mental model (memorise this). A WebSocket is one persistent full-duplex TCP socket, upgraded from HTTP via a 101 handshake, over which either side sends lightweight text/binary frames at will: it is ideal for bidirectional low-latency traffic, but you own reconnection (exponential backoff + resync), heartbeats to detect dead sockets, handshake-time auth, and — at scale — a pub/sub backplane with sticky sessions; Socket.IO bundles reconnection, fallback, rooms and events on top, and in React you must remove listeners on cleanup to avoid duplicates.
A WebSocket is the frontend counterpart to a **Vert.x event-bus** connection or a long-lived **gRPC bidirectional stream**: a single persistent channel over which either side pushes framed messages, versus HTTP's one-shot request/response. Socket.IO's **rooms and named events** are pub/sub topics, and its acknowledgements are request-reply over the stream. Authenticating on the handshake or first frame is exactly gating a stream subscription before you let data flow. The scaling story maps one-to-one: stateful sticky connections plus a **Redis/Kafka pub/sub backplane** so any node can reach any client is precisely how you cluster a Vert.x app across the distributed event bus, and heartbeats/ping-pong are the same liveness checks a service mesh or gRPC keepalive performs to reap half-open connections. Exponential backoff with jitter on reconnect is the identical resilience pattern you apply to any client of a flaky downstream.
- A WebSocket is a persistent, full-duplex connection over one TCP socket; either side can send at any time with tiny framing overhead.
- It starts as an HTTP request and is upgraded via a 101 Switching Protocols handshake; use wss:// (TLS) in production.
- Data travels as text or binary frames, plus ping/pong keepalive and close control frames — far cheaper than HTTP headers per message.
- The native API is event-driven (onopen/onmessage/onerror/onclose); check readyState before sending or send() throws.
- A raw WebSocket does nothing on failure — you must reconnect with exponential backoff and jitter, then re-auth, re-subscribe, and resync missed state.
- Use heartbeats (ping/pong with a timeout) to detect half-open/dead connections that look alive but no longer carry data.
- The browser can't set an Authorization header on the handshake, so auth goes via query param/cookie or a first-frame auth message.
- Socket.IO adds auto-reconnection, HTTP long-polling fallback, rooms, acknowledgements, namespaces, and an event API — but is not raw-WebSocket compatible.
- In React, register socket.on handlers in useEffect and remove them with socket.off in cleanup to avoid duplicate handlers; share one connection per app.
- Scaling needs sticky sessions at the load balancer and a pub/sub backplane (Redis/Kafka) so any node can broadcast to any connected client.
Worked Code
// Always wss:// (TLS) in production. The connection upgrades from HTTP via a 101.
const socket = new WebSocket('wss://api.example.com/ws');
socket.onopen = () => {
// Connected. Only now is it safe to send. Authenticate on the first frame.
socket.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') }));
};
socket.onmessage = (event) => {
// Frames arrive here; event.data is text (JSON) or binary (ArrayBuffer/Blob).
const msg = JSON.parse(event.data);
console.log('received', msg.type, msg.payload);
};
socket.onerror = (err) => console.error('socket error', err);
socket.onclose = (event) => {
// event.code / event.reason explain why. A raw socket does NOTHING to reconnect.
console.log('closed', event.code, event.reason);
};
// Guard sends with readyState, or send() throws when not OPEN.
function safeSend(data: unknown) {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
}// A small resilient wrapper — the logic Socket.IO gives you for free.
function createResilientSocket(url: string, onMessage: (m: unknown) => void) {
let ws: WebSocket;
let attempt = 0;
let heartbeat: ReturnType<typeof setInterval>;
function connect() {
ws = new WebSocket(url);
ws.onopen = () => {
attempt = 0; // reset backoff on success
// Heartbeat: ping periodically; if the server stops responding, the
// next close/timeout triggers a reconnect. Detects half-open sockets.
heartbeat = setInterval(() => safeSend({ type: 'ping' }), 25000);
// Re-subscribe / resync any state missed while disconnected here.
};
ws.onmessage = (e) => onMessage(JSON.parse(e.data));
ws.onclose = () => {
clearInterval(heartbeat);
// Exponential backoff capped at 30s, with jitter to avoid a thundering herd.
const base = Math.min(1000 * 2 ** attempt, 30000);
const delay = base / 2 + Math.random() * (base / 2);
attempt++;
setTimeout(connect, delay);
};
}
function safeSend(data: unknown) {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
}
connect();
return { send: safeSend, close: () => ws.close() };
}import { io, type Socket } from 'socket.io-client';
import { useEffect, useState } from 'react';
// ONE shared connection for the whole app (Socket.IO handles reconnect/backoff).
const socket: Socket = io(import.meta.env.VITE_WS_URL, {
auth: { token: localStorage.getItem('token') }, // sent on the handshake
transports: ['websocket'], // skip long-polling if not needed
});
type Notice = { id: string; text: string };
export function useNotifications() {
const [notices, setNotices] = useState<Notice[]>([]);
useEffect(() => {
const onApproved = (n: Notice) => setNotices((prev) => [n, ...prev]);
const onReconnect = () => {/* re-join rooms / refetch missed state */};
socket.on('expense:approved', onApproved);
socket.io.on('reconnect', onReconnect);
// CRITICAL: remove handlers on cleanup, or re-renders stack duplicates
// and each event fires the callback multiple times.
return () => {
socket.off('expense:approved', onApproved);
socket.io.off('reconnect', onReconnect);
};
}, []);
// Bidirectional: the client can send too, optionally with an ack callback.
const markRead = (id: string) =>
socket.emit('notification:read', { id }, (ack: { ok: boolean }) => console.log(ack));
return { notices, markRead };
}// Node + ws, multiple instances behind a load balancer with sticky sessions.
// Problem: a client on server A must receive messages published on server B.
// Solution: a Redis pub/sub backplane so any node can broadcast to any client.
const { WebSocketServer } = require('ws');
const { createClient } = require('redis');
const wss = new WebSocketServer({ port: 8080 });
const pub = createClient(); // publisher
const sub = createClient(); // subscriber (must be a separate connection)
(async () => {
await pub.connect();
await sub.connect();
// Any message published to the channel is fanned out to THIS node's clients.
await sub.subscribe('broadcast', (raw) => {
for (const client of wss.clients) {
if (client.readyState === 1 /* OPEN */) client.send(raw);
}
});
})();
wss.on('connection', (ws) => {
ws.on('message', (data) => {
// Publish to Redis instead of only this node's clients — reaches every instance.
pub.publish('broadcast', data.toString());
});
});
// Socket.IO offers @socket.io/redis-adapter which does exactly this for you.Interview-Ready Q&A
A WebSocket connection begins as a normal HTTP GET carrying Upgrade: websocket, Connection: Upgrade, and a random Sec-WebSocket-Key. If the server accepts, it responds with HTTP 101 Switching Protocols and a Sec-WebSocket-Accept header derived from the key. From that point the same TCP connection is upgraded and speaks the WebSocket frame protocol instead of HTTP. The scheme is ws:// or, over TLS, wss://, which you should always use in production.
- 1WebSocket = persistent, full-duplex connection over one TCP socket; either side sends any time.
- 2It upgrades from HTTP via a 101 Switching Protocols handshake; always use wss:// in production.
- 3Data is text or binary frames plus ping/pong/close control frames — far cheaper than HTTP per message.
- 4Native API is event-driven (onopen/onmessage/onerror/onclose); check readyState before send().
- 5A raw socket does not reconnect — you own exponential backoff + jitter, re-auth, re-subscribe, and resync.
- 6Use heartbeats (ping/pong with timeout) to detect half-open/dead connections.
- 7The browser can't set an Authorization header on the handshake — auth via query param, cookie, or first-frame message.
- 8Socket.IO adds auto-reconnect, long-polling fallback, rooms, acks, namespaces, and events (not raw-WS compatible).
- 9In React: register handlers in useEffect, remove them with socket.off in cleanup; share one connection app-wide.
- 10Scaling needs sticky sessions + a pub/sub backplane (Redis/Kafka) so any node can reach any client.