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.

← all articles wleeaf.dev →