---
title: 'WebSocket Server'
source: 'https://academia.sh/en/courses/asynchronous-processing/websocket-server'
course: 'Caching, Queues and Asynchronous Processing'
language: en
updated: '2026-08-23T07:00:24+00:00'
license: 'CC BY-SA 4.0'
---

# WebSocket Server

Building a two-way connection from scratch: the upgrade handshake over HTTP and generating the accept key, writing and parsing the frame format, the ping–pong exchange and the closing handshake, and how connection state affects horizontal scaling.

Server-sent events were one-way: the server talks, the client listens. The library
service has cases where both sides talk — a clerk correcting a branch stock counter, a
member withdrawing from the reservation queue. Opening a separate HTTP request for each
of these gives back the latency the stream gained.

**WebSocket** is the transport that answers this need: a single connection that starts
over HTTP, switches to its own frame format after a handshake, and carries messages in
both directions. This lesson builds it without using a ready-made package, because the
one thing that cannot be learned without building it is **what the connection holds onto**
and why that causes trouble at scale.

## The Upgrade Handshake

The connection starts with an ordinary HTTP request. The client sends the
`Upgrade: websocket` and `Connection: Upgrade` headers, along with a randomly generated
sixteen-byte value encoded in base64, in the `Sec-WebSocket-Key` header.

The server concatenates this key with a GUID fixed in the specification, takes its
SHA-1 digest, encodes it in base64, and returns the result in the
`Sec-WebSocket-Accept` header. The response's status code is **101 Switching
Protocols**, and from this point on there is no HTTP on the connection.

```js
// accept-key.mjs — generation and verification of the accept value in the handshake
import { createHash } from "node:crypto";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  // value fixed in the protocol
const key = "dGhlIHNhbXBsZSBub25jZQ==";               // sample client key from the specification
const accept = createHash("sha1").update(key + GUID).digest("base64");

console.log("Sec-WebSocket-Key   :", key);
console.log("Sec-WebSocket-Accept:", accept);
console.log("specification value :", accept === "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
```

```
Sec-WebSocket-Key   : dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
specification value : true
```

This computation **has no security function**. SHA-1 here is not a signature but a
check: it shows that the server genuinely understood the request as WebSocket and that
an intermediate cache is not replaying an old response. Authentication must be done
separately, in the upgrade request itself — in a cookie, a header, or the first message.

## Frame Format

After the handshake, **frames** are carried on the connection. A frame's header is at
least two bytes. The high bit of the first byte is `FIN`, which says whether the message
ends with this frame; the low four bits are the **opcode**: text, binary, close, ping,
pong.

The high bit of the second byte is the **mask bit**, and the remaining seven bits are
the length. A length up to 125 is written directly; the value 126 announces that the
next two bytes carry the real length, and 127 that the next eight bytes do.

The masking rule is one-directional: every frame going from client to server **must**
be masked, and none going from server to client should be. Masking provides no
confidentiality — the key is inside the frame. Its purpose is to stop an old
intermediary from mistaking the frame body for a valid HTTP request and poisoning its
cache.

```js
// frame.mjs — writes and parses the WebSocket frame format
import { randomBytes } from "node:crypto";

export const OPCODE = { text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa };

// Frame: [FIN + opcode][mask bit + length][extended length?]
//        [mask key?][body].  Every frame coming from the client must be masked.
export function writeFrame(opcode, body, masked = false) {
  const data = Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
  const n = data.length;
  const ext = n < 126 ? 0 : n < 65536 ? 2 : 8;
  const header = Buffer.alloc(2 + ext + (masked ? 4 : 0));
  header[0] = 0x80 | opcode;                       // FIN = 1: frame is a single piece
  header[1] = (masked ? 0x80 : 0) | (ext === 0 ? n : ext === 2 ? 126 : 127);
  if (ext === 2) header.writeUInt16BE(n, 2);
  if (ext === 8) header.writeBigUInt64BE(BigInt(n), 2);
  if (!masked) return Buffer.concat([header, data]);
  const key = randomBytes(4);
  key.copy(header, 2 + ext);
  const maskedBody = Buffer.from(data);
  for (let i = 0; i < n; i++) maskedBody[i] ^= key[i % 4];
  return Buffer.concat([header, maskedBody]);
}

// Parses one full frame from the buffer; returns null if the frame is incomplete.
export function parseFrame(buffer) {
  if (buffer.length < 2) return null;
  const opcode = buffer[0] & 0x0f, fin = (buffer[0] & 0x80) !== 0;
  const masked = (buffer[1] & 0x80) !== 0;
  let n = buffer[1] & 0x7f, offset = 2;
  if (n === 126) { if (buffer.length < 4) return null; n = buffer.readUInt16BE(2); offset = 4; }
  else if (n === 127) {
    if (buffer.length < 10) return null;
    n = Number(buffer.readBigUInt64BE(2)); offset = 10;
  }
  const key = masked ? buffer.subarray(offset, offset + 4) : null;
  if (masked) offset += 4;
  if (buffer.length < offset + n) return null;
  const body = Buffer.from(buffer.subarray(offset, offset + n));
  if (key) for (let i = 0; i < n; i++) body[i] ^= key[i % 4];
  return { opcode, fin, masked, body, consumed: offset + n };
}
```

The fact that `parseFrame` returns `null` on an incomplete frame is not a detail, it is a
necessity. The layer underneath is TCP, and TCP is a **byte stream**: a frame that was
sent can arrive in two pieces, and two frames can arrive in one piece. This is why the
reading side accumulates incoming bytes and pulls as many frames as it can parse from
the buffer.

## Server

The server attaches to the HTTP server's `upgrade` event. This event hands over the raw
socket when an upgrade request arrives; the server itself writes the response line and
headers.

```js
// ws-server.mjs — a WebSocket server that does its own handshake and frame parsing
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { OPCODE, writeFrame, parseFrame } from "./frame.mjs";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const generateAccept = (key) => createHash("sha1").update(key + GUID).digest("base64");

const clients = new Set();
const log = (...s) => console.log(...s);

const server = createServer((request, response) => {
  response.sendDate = false;
  response.writeHead(426, { "content-type": "text/plain", upgrade: "websocket" });
  response.end("this endpoint only accepts WebSocket connections\n");
});

server.on("upgrade", (request, socket) => {
  const key = request.headers["sec-websocket-key"];
  if (request.headers.upgrade?.toLowerCase() !== "websocket" || !key)
    return socket.destroy();

  socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" +
               `Connection: Upgrade\r\nSec-WebSocket-Accept: ${generateAccept(key)}\r\n\r\n`);
  clients.add(socket);
  log(`connection opened (open=${clients.size})`);

  const send = (opcode, body) => socket.write(writeFrame(opcode, body));  // server does not mask
  let buffer = Buffer.alloc(0);

  socket.on("data", (chunk) => {
    buffer = Buffer.concat([buffer, chunk]);
    let frame;
    while ((frame = parseFrame(buffer))) {
      buffer = buffer.subarray(frame.consumed);
      log(`frame received    opcode=0x${frame.opcode.toString(16)}` +
          ` masked=${frame.masked} length=${frame.body.length}`);

      if (frame.opcode === OPCODE.text) {
        const message = JSON.parse(frame.body.toString());
        send(OPCODE.text, JSON.stringify({ type: "ack", branch: message.branch, newStock: 41 }));
      } else if (frame.opcode === OPCODE.ping) {
        send(OPCODE.pong, frame.body);              // answers a ping with a pong using the same body
      } else if (frame.opcode === OPCODE.close) {
        const code = frame.body.length >= 2 ? frame.body.readUInt16BE(0) : 1005;
        log(`close request     code=${code} reason=${frame.body.subarray(2).toString()}`);
        send(OPCODE.close, frame.body);              // closing handshake: reply with the same code
        socket.end();
      }
    }
  });

  socket.on("close", () => {
    clients.delete(socket);
    log(`connection closed (open=${clients.size})`);
  });
});

server.listen(8381, "127.0.0.1", () => log("listening: 127.0.0.1:8381"));
```

## Client

The client uses the same format, with two differences: it writes the handshake request
itself, and it masks every frame it sends.

```js
// ws-client.mjs — a small WebSocket client that hand-shakes and sends frames over raw TCP
import { connect } from "node:net";
import { createHash, randomBytes } from "node:crypto";
import { OPCODE, writeFrame, parseFrame } from "./frame.mjs";

const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const key = randomBytes(16).toString("base64");
const expectedAccept = createHash("sha1").update(key + GUID).digest("base64");

const queue = [], waiting = [];
const receiveFrame = () => queue.length
  ? Promise.resolve(queue.shift())
  : new Promise((c) => waiting.push(c));
const frameArrived = (f) => (waiting.length ? waiting.shift()(f) : queue.push(f));

let buffer = Buffer.alloc(0), handshakeDone = false;
const socket = connect(8381, "127.0.0.1", () => {
  socket.write("GET /channel HTTP/1.1\r\nHost: 127.0.0.1:8381\r\n" +
               "Upgrade: websocket\r\nConnection: Upgrade\r\n" +
               `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
});

socket.on("error", (err) => {                      // server is not up: exit quietly
  console.log("could not connect to server:", err.code);
  process.exit(0);
});

socket.on("data", (chunk) => {
  buffer = Buffer.concat([buffer, chunk]);
  if (!handshakeDone) {
    const end = buffer.indexOf("\r\n\r\n");
    if (end < 0) return;
    const headers = buffer.subarray(0, end).toString();
    buffer = buffer.subarray(end + 4);
    handshakeDone = true;
    console.log("status line       :", headers.split("\r\n")[0]);
    console.log("accept key        :",
      /sec-websocket-accept: (.+)/i.exec(headers)?.[1] === expectedAccept ? "correct" : "incorrect");
    talk();
  }
  let f;
  while ((f = parseFrame(buffer))) { buffer = buffer.subarray(f.consumed); frameArrived(f); }
});

async function talk() {
  const send = (opcode, body) => socket.write(writeFrame(opcode, body, true));

  send(OPCODE.text, JSON.stringify({ type: "stock", branch: 3, delta: -1 }));
  const res1 = await receiveFrame();
  console.log("text reply        :", res1.body.toString(), `(masked=${res1.masked})`);

  send(OPCODE.ping, "is-alive");
  const res2 = await receiveFrame();
  console.log("ping reply        : opcode=0x" + res2.opcode.toString(16), res2.body.toString());

  const reason = Buffer.concat([Buffer.from([0x03, 0xe8]), Buffer.from("job done")]);
  send(OPCODE.close, reason);                       // 0x03e8 = 1000, normal closure
  const res3 = await receiveFrame();
  console.log("close reply       : opcode=0x" + res3.opcode.toString(16),
              "code=" + res3.body.readUInt16BE(0));
  socket.end();
}
```

## Lifecycle

The script below first sends an HTTP request without upgrading, then runs the client.
Port 8381 is arbitrary and must be free.

```bash
#!/usr/bin/env bash
# Starts ws-server.mjs, first sends a plain HTTP request without upgrading, then runs the client, stops it.
node ws-server.mjs > server.log & server=$!
sleep 1

echo "--- plain HTTP request without upgrading ---"
curl -sS -i http://127.0.0.1:8381/channel | head -3
echo "--- what the client sees ---"
node ws-client.mjs
sleep 0.3
echo "--- the server's log ---"
cat server.log
kill "$server"
```

```
--- plain HTTP request without upgrading ---
HTTP/1.1 426 Upgrade Required
content-type: text/plain
upgrade: websocket
--- what the client sees ---
status line       : HTTP/1.1 101 Switching Protocols
accept key        : correct
text reply        : {"type":"ack","branch":3,"newStock":41} (masked=false)
ping reply        : opcode=0xa is-alive
close reply       : opcode=0x8 code=1000
--- the server's log ---
listening: 127.0.0.1:8381
connection opened (open=1)
frame received    opcode=0x1 masked=true length=38
frame received    opcode=0x9 masked=true length=8
frame received    opcode=0x8 masked=true length=10
close request     code=1000 reason=job done
connection closed (open=0)
```

The log shows the lifecycle's four phases. **Opening**: the upgrade is accepted, the
connection counter increases. **Data**: the text frame from the client is masked, the
reply that comes back is not. **Control**: the ping (0x9) frame is answered with a pong
(0xa) using the same body. **Closing**: the 0x8 frame is a closing **request**, not a
command; the socket closes after the other side sends its own close frame.

The ping–pong pair is indispensable in real deployments. A TCP connection can appear
open for a long time when the other side has silently vanished; a ping sent at regular
intervals not being answered with a pong is the only reliable sign that the connection
is dead. The same traffic also stops intermediaries from closing an idle connection —
it is the form here that corresponds to the heartbeat line in server-sent events.

## Scaling

The real difference between WebSocket and HTTP is not in the headers, it is in the
**state**. An ordinary HTTP request is stateless; it gives the same result no matter
which server instance it lands on. A WebSocket connection, on the other hand, **binds**
to an instance and stays there for the connection's duration.

This has three consequences. The unit of scaling is now the connection, not the
request; the server's capacity is measured not by requests per second but by how many
sockets and how much memory it can hold at once. Redeployment breaks every open
connection; reconnecting and refreshing state on the client side becomes mandatory. And
most importantly: two clients listening to the same channel can end up on **different
instances**. A message arriving at one instance does not reach the other's client on its
own.

This third consequence is the fundamental problem of every multi-instance real-time
deployment.

## Summary

- A WebSocket connection starts with an HTTP request; the server concatenates the client's key with a fixed GUID, returns its SHA-1 digest as base64, and switches the protocol with 101.
- The accept key is not a security measure; it is a check that the request was understood as WebSocket and that an old response has not been replayed.
- The frame header carries the FIN bit, the opcode, the mask bit, and the length; every frame from the client is masked, and every frame from the server is unmasked.
- Because the layer underneath is a byte stream, the parser must wait on an incomplete frame and pull as many frames as it can parse from the buffer.
- The lifecycle is opening, data, ping–pong, and a two-sided closing handshake; a connection binding to one instance is what makes the unit of scaling the connection rather than the request.

## Next Step

This lesson had a single server instance and a single client; where the message would go
was clear. In a real deployment, clients listening to the same channel are spread across
different instances: of three clerks watching a branch stock counter, two might be
connected to the first instance and one to the second. An update arriving at the first
instance does not reach the clerk on the second instance — because nobody forwards that
message to them. The next lesson builds the structure that closes this gap and shows, by
running it, that a message from one instance actually reaches another instance's
subscriber.
