Lesson 23 / 25
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.
Contents
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.
// 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.
// 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.
// 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.
// 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.
#!/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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.