Building a WebSocket Server From Scratch in Node.js
Libraries like Socket.IO and ws abstract away the WebSocket protocol. That is usually fine. But understanding what happens at the protocol level makes debugging easier and helps you make better architecture decisions.
Let us build a WebSocket server from scratch using only the Node.js standard library.
The Handshake
WebSocket starts as an HTTP upgrade request. The client sends:
GET / HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The server responds:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The Sec-WebSocket-Accept value is computed from the client’s key:
const crypto = require('crypto');
function computeAcceptKey(clientKey) {
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
return crypto
.createHash('sha1')
.update(clientKey + MAGIC)
.digest('base64');
}
The magic string is literally part of the RFC. Every WebSocket implementation uses it.
The Server
const http = require('http');
const crypto = require('crypto');
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const server = http.createServer();
const clients = new Set();
server.on('upgrade', (req, socket) => {
const clientKey = req.headers['sec-websocket-key'];
if (!clientKey) {
socket.destroy();
return;
}
const acceptKey = crypto
.createHash('sha1')
.update(clientKey + MAGIC)
.digest('base64');
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'
);
clients.add(socket);
console.log(`Client connected (${clients.size} total)`);
socket.on('data', (buffer) => {
const message = decodeFrame(buffer);
if (message === null) return;
console.log('Received:', message);
// Echo to all clients
for (const client of clients) {
client.write(encodeFrame(message));
}
});
socket.on('close', () => {
clients.delete(socket);
console.log(`Client disconnected (${clients.size} total)`);
});
socket.on('error', () => {
clients.delete(socket);
});
});
server.listen(8080, () => {
console.log('WebSocket server on ws://localhost:8080');
});
Frame Decoding
WebSocket messages are wrapped in frames. The frame format:
0 1 2 3
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|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| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking key (if MASK set) |
+-------------------------------+-------------------------------+
| Payload Data |
+---------------------------------------------------------------+
Client-to-server frames are always masked. Server-to-client frames are never masked. Here is the decoder:
function decodeFrame(buffer) {
const firstByte = buffer[0];
const opcode = firstByte & 0x0f;
// 0x1 = text, 0x8 = close, 0x9 = ping, 0xA = pong
if (opcode === 0x8) return null; // close frame
const secondByte = buffer[1];
const isMasked = (secondByte & 0x80) !== 0;
let payloadLength = secondByte & 0x7f;
let offset = 2;
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let maskingKey;
if (isMasked) {
maskingKey = buffer.slice(offset, offset + 4);
offset += 4;
}
const payload = buffer.slice(offset, offset + payloadLength);
if (isMasked) {
for (let i = 0; i < payload.length; i++) {
payload[i] ^= maskingKey[i % 4];
}
}
return payload.toString('utf-8');
}
Frame Encoding
Sending from server to client is simpler because we do not mask:
function encodeFrame(message) {
const payload = Buffer.from(message, 'utf-8');
const length = payload.length;
let header;
if (length < 126) {
header = Buffer.alloc(2);
header[0] = 0x81; // FIN + text opcode
header[1] = length;
} else if (length < 65536) {
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]);
}
Testing It
Open a browser console:
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (e) => console.log('Got:', e.data);
ws.onopen = () => ws.send('hello from browser');
Or use a Node.js client:
const net = require('net');
const crypto = require('crypto');
const key = crypto.randomBytes(16).toString('base64');
const socket = net.createConnection(8080, 'localhost', () => {
socket.write(
'GET / HTTP/1.1\r\n' +
'Host: localhost:8080\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Key: ${key}\r\n` +
'Sec-WebSocket-Version: 13\r\n' +
'\r\n'
);
});
What This Does Not Handle
This implementation is deliberately minimal. A production WebSocket server also needs:
- Ping/pong frames for connection keepalive
- Fragmented messages where large payloads span multiple frames
- Close handshake with proper status codes
- Per-message compression (the permessage-deflate extension)
- Backpressure when clients read slower than you write
That is why libraries like ws exist. But knowing the protocol means you understand what those libraries are doing, and you can debug problems at the frame level instead of guessing.
The entire WebSocket protocol fits in RFC 6455, which is surprisingly readable as RFCs go. The core framing logic is maybe 100 lines of code. Everything else is just handling edge cases.