All parts · Chat at Scale
Real-Time Chat at Scale — Part 01Networking

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.

Syed Wajeeh Haider
Syed Wajeeh Haider
Full Stack Software Engineer
Sep 28, 20268 min read
#WebSockets#Real-Time#Node.js#Networking

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.

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=
Client request and server response (from RFC 6455)

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.

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='
How the server computes Sec-WebSocket-Accept

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.

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'));
server/src/index.ts: broadcast chat on ws v8

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.

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);
}
A browser client using the native WebSocket API

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. 01Reconnection. The native WebSocket never reconnects. You have to write backoff with jitter, and re-subscribe to everything afterwards.
  2. 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.
  3. 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.
  4. 04Rooms. Broadcasting to everyone is easy. Broadcasting only to the 5 members of one conversation means maintaining your own maps of rooms to sockets.
  5. 05Multiple 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. 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.

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

The series — Real-Time Chat at Scale
(10)Let's talk

Ready to scaleyour application?

Stop worrying about technical debt. I can help you build a reliable, high-performance product from the ground up, tailored exactly to your business needs.

Actively open for new opportunities
Get in touch
Local time
--:-- PKT
© 2026 Syed Wajeeh Haider. All rights reserved.