Series: Pure Node.js — Zero Dependencies

Post #8: WebSockets From Scratch

📅 March 2026 ⏱ 12 min read 🏷 Node.js, WebSockets, TCP, Real-time

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch #2: Manual Routing — No Express Needed #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI → #8: WebSockets From Scratch (you are here) #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

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 into crypto module
  • 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 WebSocket API 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.

Building along? Share on X/Twitter or GitHub.