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.
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.
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.
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.
- —
0x1text and0x2binary carry your data. Text frames must be valid UTF-8. - —
0x0continuation frames let a large message be split across several frames. - —
0x8close starts the closing handshake and carries a status code such as1000(normal) or1008(policy violation). - —
0x9ping and0xApong 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.
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.
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);
}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:
- 01Reconnection. The native
WebSocketnever reconnects. You have to write backoff with jitter, and re-subscribe to everything afterwards. - 02Message 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.
- 03Acknowledgements.
send()returns nothing. There is no way to know the server received and saved a message unless you build request IDs and replies yourself. - 04Rooms. Broadcasting to everyone is easy. Broadcasting only to the 5 members of one conversation means maintaining your own maps of rooms to sockets.
- 05Multiple servers. The
wss.clientsset only knows about connections on this process. The moment you run two instances, half your users stop receiving messages. - 06Fallbacks. 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.
- 01WebSockets from first principles: the protocol, the handshake and a raw
wschat (this part). - 02Your first chat server with Socket.IO: typed events, rooms, acknowledgements and a React hook.
- 03Authenticating connections: JWTs in the handshake, room authorization and expiring tokens.
- 04Storing messages: a Postgres schema, time-sortable IDs, idempotent writes and cursor pagination.
- 05Delivery guarantees: acks, retries, catching up after a reconnect and read receipts.
- 06Presence and typing indicators: ephemeral state in Redis with heartbeats and TTLs.
- 07Scaling out: multiple Node.js instances, the Redis adapter and sticky sessions.
- 08Tuning for a million connections: memory per socket, file descriptors and kernel limits.
- 09Load testing WebSockets with k6: connection storms, message latency and breaking points.
- 10Production 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-Acceptproves 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.
