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.

Frequently Asked Questions

Are WebSockets always faster than polling? They remove per-message overhead and the wait for the next poll, so updates arrive sooner and cost less bandwidth once traffic is steady. But they add a stateful connection your infrastructure must support, plus heartbeats, reconnection and cross-process fan-out. For updates that can be a few seconds stale, polling is often the faster thing to build and the more reliable thing to run.
Do I need Socket.IO, or is plain ws enough? Plain ws is a thin WebSocket implementation and is fine when you control both ends and are happy writing your own heartbeat, reconnect and message routing. Socket.IO bundles those plus rooms, acknowledgements, a fallback transport and an adapter for multi-process broadcasting. It is not a WebSocket client, though: a Socket.IO client must talk to a Socket.IO server, so it is a protocol choice, not just a library choice.
How do I authenticate a WebSocket connection? The browser API cannot set an Authorization header, so you have three practical options. Rely on cookies if you use cookie sessions and check the Origin header carefully. Pass a short-lived token in the query string, accepting that it may appear in access logs. Or connect first and send the token as the first message, closing the socket if it does not arrive within a second or two. Whichever you pick, verify on connection and re-check permissions on sensitive actions.
What is the difference between SSE and long polling? Long polling sends a request that the server holds until it has something to say, then the client immediately sends another. SSE holds a single response open and streams many events down it, so there is no repeated request cycle and no gap between messages. SSE also defines automatic reconnection and event ids, which you would have to build yourself with long polling.
Why do my WebSocket connections drop after about a minute? Almost always an idle timeout in something between the browser and your application: a reverse proxy, a load balancer, or a mobile carrier's NAT. The connection is genuinely open at both ends, so neither side notices until it tries to write. Send an application-level ping well inside that timeout, commonly every thirty seconds, and raise the proxy's read timeout if you control it.