
Refer to the previous article in this series:
In the previous article, we traced how front-end developers hacked together "near-real-time" communication with short polling and long polling before WebSocket existed. Short polling wastes resources; long polling drains connections — both approaches simulate real-time, rather than delivering it.
What should true real-time communication look like?
WebSocket was designed exactly for this. It's not a variant of HTTP — it's an entirely separate protocol that begins with an HTTP handshake and then upgrades the connection.
A WebSocket connection starts as an ordinary HTTP request, but with two special headers that tell the server: "I want to upgrade to WebSocket."
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13Key fields explained:
Upgrade: websocket — Declares the protocol to upgrade toConnection: Upgrade — Signals this is an upgrade requestSec-WebSocket-Key — A random 16-byte value (Base64-encoded) generated by the client. It proves this is a legitimate WebSocket handshake and prevents accidental or malicious connectionsSec-WebSocket-Version — Protocol version; currently fixed at 13If the server agrees to upgrade, it responds with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=The Sec-WebSocket-Accept value is calculated as:
Sec-WebSocket-Accept = Base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))The magic string 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 is a fixed GUID defined by RFC 6455. It ensures that even if the client sends a weakly-random Key, the resulting Accept value is difficult to accidentally match — a simple but effective protocol integrity check.
From this moment on, the HTTP connection is upgraded to a WebSocket connection. The same TCP connection no longer carries HTTP messages — it carries WebSocket frames.
The fundamental unit of WebSocket communication is the frame. Here's its structure:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len==127 |
+-------------------------------+-------------------------------+
| | Masking-key (if MASK set) |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------+-------------------------------+You don't need to memorize every bit, but you should understand these core fields:
| Field | Size | Meaning |
|---|---|---|
| FIN | 1 bit | Is this the final frame of a message? 1 = message complete |
| opcode | 4 bits | Frame type: 1=text, 2=binary, 8=close, 9=ping, 10=pong |
| MASK | 1 bit | Client→server frames must be masked; server→client frames must not |
| Payload len | 7 bits | Payload length. 126 means next 2 bytes are the real length; 127 means next 8 bytes |
| Masking-key | 4 bytes | Masking key (client frames only) |
| Payload Data | variable | The actual data |
Why must client frames be masked? This is a security measure. Without masking, a malicious script could craft data that looks like a legitimate HTTP request to a non-WebSocket service (a cache poisoning attack). Masking makes the data appear "random" on the wire, preventing it from being misinterpreted.
Let's implement a complete WebSocket server in Node.js and a browser client — no third-party libraries.
// server.js
import http from "http";
import crypto from "crypto";
const WEBSOCKET_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const server = http.createServer((req, res) => {
// Only handle WebSocket upgrade requests
res.writeHead(404);
res.end();
});
// Track all connected clients
const clients = new Set();
server.on("upgrade", (req, socket, head) => {
// 1. Compute Accept
const key = req.headers["sec-websocket-key"];
const accept = crypto
.createHash("sha1")
.update(key + WEBSOCKET_MAGIC)
.digest("base64");
// 2. Send 101 handshake response
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n` +
"\r\n"
);
// 3. Add to client set
clients.add(socket);
broadcast(`User joined. ${clients.size} online`);
// 4. Listen for data frames
socket.on("data", (buffer) => {
const message = decodeFrame(buffer);
if (message !== null) {
console.log("Received:", message);
broadcast(message);
}
});
// 5. Handle disconnect
socket.on("close", () => {
clients.delete(socket);
broadcast(`User left. ${clients.size} online`);
});
});
// Broadcast a message to all clients
function broadcast(message) {
const frame = encodeFrame(message);
for (const client of clients) {
client.write(frame);
}
}
// Decode a WebSocket frame
function decodeFrame(buffer) {
const firstByte = buffer[0];
const opcode = firstByte & 0x0f;
// opcode 8 = close connection
if (opcode === 8) return null;
const secondByte = buffer[1];
const masked = (secondByte & 0x80) !== 0;
let payloadLength = secondByte & 0x7f;
let offset = 2;
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
// Simplified: 64-bit lengths are rare in practice
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let maskKey = null;
if (masked) {
maskKey = buffer.slice(offset, offset + 4);
offset += 4;
}
const payload = buffer.slice(offset, offset + payloadLength);
if (masked && maskKey) {
// XOR unmasking
for (let i = 0; i < payload.length; i++) {
payload[i] ^= maskKey[i % 4];
}
}
return payload.toString("utf8");
}
// Encode a WebSocket frame
function encodeFrame(message) {
const payload = Buffer.from(message, "utf8");
const length = payload.length;
let frame;
if (length < 126) {
frame = Buffer.alloc(2 + length);
frame[0] = 0x81; // FIN + text frame
frame[1] = length;
payload.copy(frame, 2);
} else if (length < 65536) {
frame = Buffer.alloc(4 + length);
frame[0] = 0x81;
frame[1] = 126;
frame.writeUInt16BE(length, 2);
payload.copy(frame, 4);
} else {
frame = Buffer.alloc(10 + length);
frame[0] = 0x81;
frame[1] = 127;
frame.writeBigUInt64BE(BigInt(length), 2);
payload.copy(frame, 10);
}
return frame;
}
server.listen(3000, () => {
console.log("WebSocket server running on ws://localhost:3000");
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket Chat Room</title>
</head>
<body>
<h1>WebSocket Chat Room</h1>
<div id="messages" style="border:1px solid #ccc;height:300px;overflow-y:scroll;padding:8px;"></div>
<input id="msgInput" type="text" placeholder="Type a message..." style="width:300px;" />
<button onclick="send()">Send</button>
<script>
const ws = new WebSocket("ws://localhost:3000");
ws.onopen = () => {
addMessage("✅ Connected to server");
};
ws.onmessage = (event) => {
addMessage("📩 " + event.data);
};
ws.onclose = () => {
addMessage("❌ Disconnected");
};
ws.onerror = (err) => {
addMessage("⚠️ Error: " + err.message);
};
function send() {
const input = document.getElementById("msgInput");
if (input.value && ws.readyState === WebSocket.OPEN) {
ws.send(input.value);
addMessage("📤 " + input.value);
input.value = "";
}
}
function addMessage(msg) {
const div = document.getElementById("messages");
div.innerHTML += ``;
div.scrollTop = div.scrollHeight;
}
// Send on Enter
document.getElementById("msgInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") send();
});
</script>
</body>
</html>This covers the full WebSocket client lifecycle:
| Event | Meaning |
|---|---|
onopen | Handshake succeeded, connection established |
onmessage | Received a message from the server |
onclose | Connection closed (intentionally or unexpectedly) |
onerror | An error occurred |
The four readyState values: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED.
| Dimension | Short Polling | Long Polling | WebSocket |
|---|---|---|---|
| Direction | Client → Server | Client → Server | Full-duplex |
| Connection model | New per request | Long-lived (one-time) | Persistent |
| Server push | No (waits for next poll) | Near-real-time | Native, instant push |
| Protocol overhead | HTTP headers, ~500-800 bytes each time | Same as short polling | Frame header: 2-10 bytes |
| Best for | Low-frequency status checks | Medium real-time (chat, notifications) | High-frequency interactive (games, collaboration) |
WebSocket connections can drop due to network fluctuations, proxy timeouts, or server restarts. The client must handle reconnection:
const RECONNECT_DELAY = 3000;
function connect() {
const ws = new WebSocket("ws://localhost:3000");
ws.onopen = () => {
console.log("Connected");
// Send a heartbeat ping every 30 seconds
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
};
ws.onclose = () => {
console.log(`Reconnecting in ${RECONNECT_DELAY}ms...`);
setTimeout(connect, RECONNECT_DELAY);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "pong") return; // Ignore heartbeat responses
// Handle business messages...
};
}
connect();The server should also proactively Ping to detect "zombie connections" and clean them up. RFC 6455 defines Ping/Pong frames (opcode 9/10) for this exact purpose.
wss:// (WebSocket over TLS), just as HTTP needs HTTPS. wss:// first establishes a TLS-encrypted channel, then performs the WebSocket upgrade inside that encrypted tunnel.Origin header to prevent cross-site WebSocket hijacking.upgrade event.In production, nginx typically proxies WebSocket connections:
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}The critical detail: you must set Connection "upgrade" — otherwise nginx won't forward the upgrade request.
broadcast function: don't echo a message back to the sender who sent it (use the socket object to distinguish).{ type: "join", name: "Alice" } on connect. The server maintains a nickname map and includes the sender's name in broadcast messages.Upgrade: websocket → Server computes Sec-WebSocket-Accept → Returns 101 Switching Protocols.Keywords: WebSocket, HTTP Upgrade, 101 Switching Protocols, Sec-WebSocket-Key, Sec-WebSocket-Accept, full-duplex, frame, opcode, masking, wss
Congratulations! You now understand WebSocket — true full-duplex real-time communication. You and your server can now talk freely with minimal latency.
But real-time communication is only one piece of the puzzle. In a microservices architecture, server-to-server communication matters just as much. How do services call each other efficiently? fetch isn't always the best answer. The next article explores RPC (Remote Procedure Call) — a paradigm that makes calling a remote function feel as natural as calling a local one. We'll build an RPC system from raw TCP in Node.js, then graduate to gRPC + Protocol Buffers.