What you'll learn
Quick Answer
A WebSocket is a long-lived, two-way connection between browser and server. It starts as an ordinary HTTP request carrying an Upgrade header; the server answers 101 Switching Protocols and the same TCP connection is then used to send messages in both directions with no further HTTP overhead. Use one when the server must push events the client cannot predict. For one-way updates, Server-Sent Events are simpler, survive proxies better and reconnect on their own.
Polling versus a real connection
HTTP is a question-and-answer protocol. The browser asks, the server answers, the connection's job is done. There is no way for the server to speak first. So the oldest way to fake real-time updates is to keep asking:
setInterval(async () => {
const res = await fetch('/api/messages?since=' + lastId);
render(await res.json());
}, 2000);
This is short polling, and it is worth being fair to it. It is trivial to write, it works through every proxy and firewall ever built, it needs no special server, and it recovers from a dropped network automatically because each request stands alone. For a notification badge that can be a few seconds stale, it is the right answer.
What it costs is visible once you think about the request. Every two seconds each open tab sends headers, cookies and an auth token, the server authenticates, queries, and usually replies that nothing has changed. With a thousand users that is five hundred requests a second to say "no news". And the latency is structural: on average a user waits half your interval before seeing anything, so making it feel instant means polling so often that the waste becomes the bottleneck.
A WebSocket replaces that with one connection that stays open. Either side can send a message at any time, with a small frame header rather than a full request. The server pushes the moment something happens. The price is that you now hold state per user on a specific server process, and everything difficult about WebSockets follows from that one fact.
The upgrade handshake
A WebSocket connection does not start as a WebSocket. It starts as an ordinary HTTP GET with two extra headers asking to change protocol:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
If the server agrees it replies with status 101 rather than 200:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The Sec-WebSocket-Accept value is derived from the client's key using a fixed algorithm. It is not security; it exists so that a cache or proxy which does not understand WebSockets cannot accidentally complete the handshake. From that point the TCP connection carries WebSocket frames, addressed as ws:// or, in practice always, wss:// over TLS.
Two consequences catch people out.
The browser API cannot set headers. new WebSocket(url) takes a URL and an optional subprotocol, and that is all. There is no way to attach an Authorization header. Cookies for the same site are sent, so cookie sessions work, but token auth does not fit naturally. Putting the token in the query string works and is common, but query strings end up in server and proxy access logs, so prefer sending the token as the first message after the connection opens and closing the socket if it does not arrive quickly.
Browsers do not apply the same-origin policy to WebSockets. Any website can open a socket to your server, and the user's cookies go with it. There is no preflight and no CORS to save you. The server must check the Origin header itself and reject unknown values, otherwise a malicious page can hold an authenticated connection on a logged-in user's behalf.
Writing one, and keeping it alive
The server side in Node with the ws library is small:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (socket, req) => {
if (req.headers.origin !== 'https://example.com') return socket.close();
socket.on('message', (data) => {
const msg = JSON.parse(data.toString());
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(JSON.stringify(msg));
}
});
socket.on('error', console.error);
});
And the client:
// `token` here is whatever your normal login flow already gave this page
const ws = new WebSocket('wss://example.com/ws');
ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token }));
ws.onmessage = (e) => render(JSON.parse(e.data));
ws.onclose = () => scheduleReconnect();
Now the part that separates a demo from something usable. A dead connection does not tell you it is dead. When a laptop lid closes, or a phone moves from Wi-Fi to mobile data, or a proxy silently drops an idle connection, both sides may sit there believing the socket is fine. Messages you send vanish. No error fires until something forces a write.
So you send heartbeats and hang up on anything that stops answering:
setInterval(() => {
for (const client of wss.clients) {
if (client.isAlive === false) { client.terminate(); continue; }
client.isAlive = false;
client.ping();
}
}, 30000);
wss.on('connection', (socket) => {
socket.isAlive = true;
socket.on('pong', () => { socket.isAlive = true; });
});
On the client, reconnect with exponential backoff and a random jitter. A fixed one-second retry loop means that when your server restarts, every client reconnects in the same second and knocks it over again. Assume the connection will break, because on Indian mobile networks it will break often, and design the UI to show a reconnecting state rather than pretending nothing happened.
Scaling: the bug everyone hits once
The chat app works on your machine. You deploy two Node processes behind a load balancer and messages start disappearing. Nothing in the logs is red.
The reason is that wss.clients only ever contains the sockets connected to that process. A student connected to process A sends a message; the classmate connected to process B is not in A's list and never hears it. Adding a server made the app worse, which is not intuitive when you are used to stateless HTTP.
The fix is to stop broadcasting from process memory and publish through something both processes can see, usually Redis pub/sub:
// pub and sub must be two separate Redis clients: once a connection
// is subscribed it cannot run ordinary commands such as PUBLISH
// on receive: publish instead of looping over local clients
await pub.publish('chat', JSON.stringify(msg));
// every process subscribes and fans out to its own sockets
sub.subscribe('chat', (raw) => {
for (const c of wss.clients) if (c.readyState === c.OPEN) c.send(raw);
});
Four more things bite at deploy time.
- The proxy must be told. Nginx will not forward an upgrade unless you configure it, and its default read timeout will cut idle connections after a minute, which is why your heartbeat interval must be shorter than that timeout.
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
- Sticky sessions matter if your client can fall back to long polling, because the fallback's separate HTTP requests must reach the process holding the session.
- Connections are not free. Each one holds a file descriptor and memory for its buffers, so the operating system's open-file limit becomes a real ceiling.
- Some hosting cannot do this at all. Shared PHP hosting and most request-scoped serverless platforms terminate the process when the response ends, so there is nothing left to hold a socket. If that is your hosting, choose polling or a managed real-time service rather than fighting the platform.
Deploys also become visible to users: restarting the server disconnects everyone at once, so the reconnect logic from the previous section is not optional.
Server-Sent Events and other options
Before reaching for a WebSocket, ask which direction the data flows. Live scores, notifications, build logs, order status, a progress bar: all of those are server to client only. The client sends nothing after the initial request. For that shape, Server-Sent Events are a better fit.
SSE is plain HTTP with a response that never ends. The server holds the response open and writes text lines as events occur:
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no'); // stop nginx buffering the stream
res.flushHeaders();
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 5000);
req.on('close', () => clearInterval(timer));
});
const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
The blank line after each message is part of the format, not a style choice; without the double newline the browser never dispatches the event. In exchange for the restriction to one direction you get several things free: the browser reconnects automatically, it sends Last-Event-ID so you can resume, it is ordinary HTTP so proxies and compression behave, and there is no handshake to configure.
SSE has its own gotcha. Over HTTP/1.1 a browser allows only around six connections per origin, shared across tabs, and an open SSE stream occupies one of them permanently. Open the same dashboard in seven tabs and requests start queueing forever. HTTP/2 multiplexes over a single connection and removes the problem, so serve SSE over HTTP/2 or accept the tab limit. Like WebSocket, EventSource cannot set custom headers.
The short decision list: both directions and low latency, such as chat, collaborative editing or multiplayer, use WebSockets. One direction, use SSE. Updates that can be seconds late, use polling and keep your architecture simple. Server talking to another server, use a webhook, not a socket. And never route ordinary CRUD through a socket because it is open; you would be rebuilding request-response, badly, without status codes or caching.
