How WebSockets Actually Work
WebSocket is a protocol that starts as HTTP and then upgrades to a persistent, full-duplex TCP connection. The browser sends an HTTP Upgrade request, the server responds with a handshake, and from that point on both sides can send messages freely — no request/response cycle needed.
WebSocket vs HTTP:
HTTP is request-response: client asks, server answers, connection closes.
WebSocket is full-duplex: both sides can send data at any time over a persistent connection. This is what makes real-time chat, live dashboards, and multiplayer games possible.
Step 1: Intercept the Upgrade Request
WebSocket connections start as regular HTTP requests with a special Upgrade: websocket header. We intercept them on the server's upgrade event:
// ws.js
const crypto = require('crypto');
const http = require('http');
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function handleUpgrade(req, socket) {
const key = req.headers['sec-websocket-key'];
if (!key) {
socket.destroy();
return;
}
// Generate the accept key: SHA-1(client_key + magic_string), base64
const acceptKey = crypto
.createHash('sha1')
.update(key + WS_MAGIC)
.digest('base64');
// Send the HTTP 101 Switching Protocols response
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
'\r\n'
);
// From here: socket is a raw TCP stream, not HTTP
return socket;
}
module.exports = { handleUpgrade };
Step 2: Parse WebSocket Frames
After the handshake, data is sent as WebSocket frames. Each frame has a specific binary format:
// Frame format:
// Byte 0: FIN bit + opcode (0x81 = text frame)
// Byte 1: MASK bit + payload length
// Bytes 2-5: masking key (if MASK bit set)
// Rest: payload XOR'd with masking key
function parseFrame(buffer) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
const opcode = firstByte & 0x0f;
const isMasked = Boolean((secondByte >>> 7) & 0x01);
// opcodes: 0x1 = text, 0x2 = binary, 0x8 = close, 0x9 = ping, 0xa = pong
if (opcode === 0x8) return { type: 'close' };
if (opcode === 0x9) return { type: 'ping' };
let payloadLength = secondByte & 0x7f;
let offset = 2;
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(offset);
offset += 2;
} else if (payloadLength === 127) {
payloadLength = buffer.readBigUInt64BE(offset);
offset += 8;
}
// Client frames are always masked
let payload;
if (isMasked) {
const mask = buffer.slice(offset, offset + 4);
offset += 4;
const masked = buffer.slice(offset, offset + payloadLength);
payload = Buffer.alloc(Number(payloadLength));
for (let i = 0; i < Number(payloadLength); i++) {
payload[i] = masked[i] ^ mask[i % 4];
}
} else {
payload = buffer.slice(offset, offset + payloadLength);
}
return {
type: 'message',
data: payload.toString('utf8'),
isFinalFrame
};
}
module.exports = { handleUpgrade, parseFrame };
Step 3: Send WebSocket Frames
Sending from server to client is simpler — server frames are not masked:
function buildFrame(message) {
const payload = Buffer.from(message, 'utf8');
const length = payload.length;
let header;
if (length <= 125) {
header = Buffer.alloc(2);
header[0] = 0x81; // FIN + text opcode
header[1] = length;
} else if (length <= 65535) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
return Buffer.concat([header, payload]);
}
function sendMessage(socket, message) {
socket.write(buildFrame(message));
}
module.exports = { handleUpgrade, parseFrame, sendMessage };
Step 4: Wire It Into the HTTP Server
// server.js
const http = require('http');
const { handleUpgrade, parseFrame, sendMessage } = require('./ws');
const server = http.createServer((req, res) => {
// ... normal HTTP routes
});
// Intercept WebSocket upgrade requests
server.on('upgrade', (req, socket) => {
if (req.headers.upgrade !== 'websocket') {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
return;
}
const ws = handleUpgrade(req, socket);
console.log('WebSocket client connected');
// Listen for incoming messages
ws.on('data', (buffer) => {
const frame = parseFrame(buffer);
if (frame.type === 'close') {
ws.destroy();
return;
}
if (frame.type === 'message') {
console.log('Received:', frame.data);
// Echo back for now
sendMessage(ws, `Echo: ${frame.data}`);
}
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
server.listen(3000);
Step 5: Connect from the Browser
// static/chat.js
const ws = new WebSocket('ws://localhost:3000');
ws.addEventListener('open', () => {
console.log('Connected!');
ws.send(JSON.stringify({ type: 'join', username: 'Alice' }));
});
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
console.log('Received:', msg);
});
ws.addEventListener('close', () => {
console.log('Disconnected');
});
// Send a message
ws.send(JSON.stringify({ type: 'message', text: 'Hello!' }));
Key takeaways:
- WebSocket starts as HTTP, then upgrades to raw TCP via
101 Switching Protocols - The handshake uses SHA-1(
client_key + magic_string) — built intocryptomodule - Frames are binary — FIN bit, opcode, mask bit, payload length, masking key, payload
- Client → server frames are always masked; server → client are not
- The browser's
WebSocketAPI handles all this complexity on the client side
What's Next
In Post #9, we build the full chat app on top of this WebSocket foundation — with rooms, usernames, broadcast, and a complete vanilla JS chat UI.