# WebSockets from First Principles

> Why chat needs a persistent connection, how the WebSocket handshake and frames work, and a minimal broadcast chat built on the raw ws package.

- Author: Syed Wajeeh Haider
- Published: Sep 28, 2026
- Series: Real-Time Chat at Scale, part 1 of 10
- Category: Networking
- Tags: WebSockets, Real-Time, Node.js, Networking
- Reading time: 8 min
- URL: https://syedwajeehhaider.com/websockets-from-first-principles

Chat looks simple from the outside: type a message, press enter, it appears on someone else's screen. Underneath, it breaks the request/response model that most of the web is built on. The server has to push data to a client that did not ask for it, thousands of times a second, to connections that stay open for hours. This series builds a real chat app called **Relay** from a single file to a system that holds a million concurrent connections, one step at a time. It starts here, with the protocol itself.

- **2 bytes**: minimum frame header, server to client
- **1 RTT**: to upgrade an HTTP connection
- **~0**: overhead per message vs a new HTTP request

## The problem: HTTP only speaks when spoken to

In plain HTTP the client always goes first. A browser sends a request, the server sends one response, and the exchange is over. That works for pages and APIs, but a chat server needs to tell Alice about Bob's message the instant it arrives, and Alice has no idea when that will be. Over the years there have been four main answers to that problem.

### Short polling

The client asks "anything new?" every few seconds. It is trivial to build and works everywhere, but it is wasteful and slow at the same time. Most requests come back empty, each one carries full HTTP headers and often a new TLS handshake, and a message can sit on the server for a whole polling interval before anyone sees it. 10,000 idle users polling every 2 seconds is 5,000 requests per second of pure overhead.

### Long polling

The client sends a request and the server holds it open until there is something to say or a timeout fires, then the client immediately asks again. Latency drops to near real time and empty responses mostly disappear. The cost is complexity: every message still needs a fresh request, ordering across overlapping requests is fiddly, and proxies with short idle timeouts cut connections unpredictably. Socket.IO still uses long polling as a fallback transport for exactly these hostile networks.

### Server-Sent Events

SSE keeps one HTTP response open and streams `text/event-stream` lines down it. It is simple, runs over ordinary HTTP, reconnects automatically through the browser's `EventSource` API and is a great fit for one-way feeds such as notifications, live scores or LLM token streams. It is one-directional though: the client still sends its messages with separate HTTP requests, and it is text only.

### WebSockets

A WebSocket starts life as an HTTP request and then upgrades the same TCP connection into a full-duplex channel. Both sides can send messages at any time, framing overhead is a few bytes, and there are no repeated headers. For chat, where users both send and receive constantly, this is the right tool.

- **Short polling:** simplest, highest overhead, latency equals the poll interval.
- **Long polling:** near real time, one request per message, awkward ordering.
- **SSE:** server to client only, plain HTTP, built-in reconnect, text only.
- **WebSockets:** bidirectional, tiny per-message overhead, needs connection-aware infrastructure.

## The upgrade handshake

A WebSocket connection begins with an ordinary HTTP/1.1 `GET` request that carries a few special headers. If the server agrees, it answers with `101 Switching Protocols` and from that moment the TCP connection no longer speaks HTTP.

*Client request and server response (from RFC 6455)*

```http
GET /chat HTTP/1.1
Host: relay.example.com
Upgrade: websocket
Connection: Upgrade
Origin: https://relay.example.com
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```

The `Sec-WebSocket-Key` is 16 random bytes, base64 encoded. The server appends a fixed GUID defined in the spec, takes the SHA-1 hash and base64 encodes it. That proves the server actually understands WebSockets and stops a cache or a confused proxy from replaying an old response. It is **not** a security feature: it does nothing to authenticate the user, which is the subject of part 3.

*How the server computes Sec-WebSocket-Accept*

```ts
import { createHash } from 'node:crypto';

const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

function acceptKey(secWebSocketKey: string) {
  return createHash('sha1').update(secWebSocketKey + GUID).digest('base64');
}

acceptKey('dGhlIHNhbXBsZSBub25jZQ=='); // 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
```

Notice the `Origin` header. Browsers always send it on WebSocket requests, and because WebSockets are not covered by CORS, checking it on the server is your job. We come back to this when we lock down the handshake.

## Frames, opcodes and heartbeats

After the upgrade, data travels in frames. Each frame has a small header containing a FIN bit (is this the last fragment of a message?), a 4-bit opcode, a mask bit, a payload length and then the payload. You will rarely touch frames directly, but knowing what is in them explains a lot of behaviour you will see in production.

- `0x1` text and `0x2` binary carry your data. Text frames must be valid UTF-8.
- `0x0` continuation frames let a large message be split across several frames.
- `0x8` close starts the closing handshake and carries a status code such as `1000` (normal) or `1008` (policy violation).
- `0x9` ping and `0xA` pong are control frames used to check that the other side is still there.
- Frames from client to server are always masked with a random 4-byte key; frames from server to client are not.

Ping and pong matter more than they look. A TCP connection can die silently: a phone switches from Wi-Fi to 4G, a laptop lid closes, a NAT gateway drops an idle mapping. Neither side gets a close frame, and the server keeps a dead socket (and its memory) around forever. The standard fix is a **heartbeat**: the server pings every client periodically and terminates any client that did not pong since the last round.

## A minimal broadcast chat with ws

Before reaching for a framework, it is worth building the smallest possible chat on the raw `ws` package so that everything Socket.IO adds later has a reason to exist. This is the whole server.

*server/src/index.ts: broadcast chat on ws v8*

```ts
import { createServer } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';

const server = createServer();
const wss = new WebSocketServer({ server, maxPayload: 16 * 1024 });

type Client = WebSocket & { isAlive?: boolean };

wss.on('connection', (ws: Client, req) => {
  ws.isAlive = true;
  ws.on('pong', () => (ws.isAlive = true));

  ws.on('message', (data, isBinary) => {
    if (isBinary) return; // text only for now
    const text = data.toString().slice(0, 2000);

    for (const client of wss.clients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({ body: text, at: Date.now() }));
      }
    }
  });

  ws.on('error', console.error);
});

// Heartbeat: drop connections that stopped answering pings.
const interval = setInterval(() => {
  for (const ws of wss.clients as Set<Client>) {
    if (!ws.isAlive) {
      ws.terminate();
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);

wss.on('close', () => clearInterval(interval));

server.listen(8080, () => console.log('Relay on :8080'));
```

Passing `server` lets the WebSocket server share a port with ordinary HTTP routes, which you will want for health checks and REST endpoints. `maxPayload` caps each message at 16 KB so one client cannot send you a 100 MB frame. The browser side needs no library at all.

*A browser client using the native WebSocket API*

```ts
const ws = new WebSocket('ws://localhost:8080');

ws.addEventListener('open', () => console.log('connected'));

ws.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data as string);
  console.log(new Date(msg.at).toLocaleTimeString(), msg.body);
});

ws.addEventListener('close', (event) => {
  console.log('closed', event.code, event.reason);
});

function send(text: string) {
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
}
```

> **Use wss:// in production:** Always serve WebSockets over TLS (`wss://`). Besides encryption, many corporate proxies and mobile carriers interfere with unencrypted upgrade requests, and TLS connections sail through far more reliably.

## Where raw WebSockets start to hurt

Open two tabs and it works. Now imagine turning this into a product. Almost immediately you need a list of features that the protocol does not give you:

1. **Reconnection.** The native `WebSocket` never reconnects. You have to write backoff with jitter, and re-subscribe to everything afterwards.
2. **Message types.** Everything is a string or a buffer. You need an envelope format to tell a chat message from a typing indicator from a read receipt.
3. **Acknowledgements.** `send()` returns nothing. There is no way to know the server received and saved a message unless you build request IDs and replies yourself.
4. **Rooms.** Broadcasting to everyone is easy. Broadcasting only to the 5 members of one conversation means maintaining your own maps of rooms to sockets.
5. **Multiple servers.** The `wss.clients` set only knows about connections on this process. The moment you run two instances, half your users stop receiving messages.
6. **Fallbacks.** Some networks block upgrades entirely. You need a way to degrade to HTTP long polling.

Every serious chat system ends up building these. You can write them yourself on top of `ws`, and some large systems do, but Socket.IO provides all of them with a well-tested design, and it has an adapter model that solves the multi-server problem in part 7. Relay will use Socket.IO from part 2 onwards.

> The WebSocket protocol gives you a pipe. A chat system is everything you have to build around that pipe.

## The roadmap for this series

Each part adds one layer to Relay and leaves you with working code. General scaling ideas such as capacity maths, caching, database pooling and load testing are covered in depth in the **Scaling to 1M Requests** series, and this series builds on them rather than repeating them.

1. **WebSockets from first principles:** the protocol, the handshake and a raw `ws` chat (this part).
2. **Your first chat server with Socket.IO:** typed events, rooms, acknowledgements and a React hook.
3. **Authenticating connections:** JWTs in the handshake, room authorization and expiring tokens.
4. **Storing messages:** a Postgres schema, time-sortable IDs, idempotent writes and cursor pagination.
5. **Delivery guarantees:** acks, retries, catching up after a reconnect and read receipts.
6. **Presence and typing indicators:** ephemeral state in Redis with heartbeats and TTLs.
7. **Scaling out:** multiple Node.js instances, the Redis adapter and sticky sessions.
8. **Tuning for a million connections:** memory per socket, file descriptors and kernel limits.
9. **Load testing WebSockets with k6:** connection storms, message latency and breaking points.
10. **Production hardening:** rate limits, backpressure, graceful deploys and push notifications.

## Key takeaways

- Polling and long polling fake server push over request/response; WebSockets give you a real bidirectional channel.
- SSE is excellent for one-way feeds, but chat sends in both directions all the time.
- The handshake is an HTTP upgrade; `Sec-WebSocket-Accept` proves protocol support, not identity.
- Dead connections are silent. Use ping/pong heartbeats to find and terminate them.
- Raw WebSockets lack reconnection, acks, rooms and multi-server fan-out, which is why Relay moves to Socket.IO.

Next up, part 2: **Your First Chat Server with Socket.IO**, where we rebuild Relay with typed events, conversation rooms, acknowledgements and a React hook.
