DEV Community

Cover image for Building a WebSocket Server in Pure Node.js (No Libraries)
Pedram
Pedram

Posted on

Building a WebSocket Server in Pure Node.js (No Libraries)

Recently I had a question: how does a WebSocket work backstage?\
I had used WebSockets before (mostly with socket.io or ws), but I realized I didn't really understand what was happening under the hood.

So I decided to learn it properly - by building a WebSocket server from scratch, using only Node.js built-in modules.

And the result became this step-by-step article.


What We're Building

By the end of this tutorial you'll have:

  • A pure Node.js WebSocket server (no libraries)

  • A browser client using the native WebSocket API

  • The ability to:

    • complete the WebSocket handshake
    • receive messages (decode frames)
    • send messages back (encode frames)

This is not production-ready - it's learning-ready, and that's the point.


What You Should Know First (Short & Clear)

HTTP vs WebSocket

HTTP is:

✅ request → response → connection closes

WebSocket is:

✅ connection stays open\
✅ client and server can send messages anytime

WebSockets are perfect for chat, notifications, live dashboards, typing indicators, etc.


Project Setup

Create a folder and two files:

pure-ws/
server.js
client.html


Step 1 - Create a normal HTTP server

Let's start with something familiar: an HTTP server.

Create server.js:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("Hello HTTP\n");
});

server.listen(4000, () => {
  console.log("Server running on http://localhost:4000");
});
Enter fullscreen mode Exit fullscreen mode

Run it:

node server.js

Open:

  • http://localhost:4000

✅ If you see Hello HTTP, you're good.


Step 2 - Listen for WebSocket upgrade requests

A WebSocket connection starts as an HTTP request, but with these headers:

  • Upgrade: websocket

  • Connection: Upgrade

Node gives us an event for that:

server.on("upgrade", (req, socket) => {});

Update server.js:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("Hello HTTP\n");
});

server.on("upgrade", (req, socket) => {
  console.log("🔥 Upgrade request received!");
  socket.end();
});

server.listen(4000, () => {
  console.log("Server running on http://localhost:4000");
});
Enter fullscreen mode Exit fullscreen mode

Now we need a client that triggers this event.


Step 3 - Create a WebSocket client in the browser

Create client.html:

<!DOCTYPE html>
<html>
  <body>
    <h1>WebSocket Client</h1>

    <script>  const socket = new WebSocket("ws://localhost:4000");

      socket.onopen = () => console.log("✅ connected");
      socket.onerror = (e) => console.log("❌ error", e);
      socket.onclose = () => console.log("🔌 closed");
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Open the file in your browser.

Your server should log:

Upgrade request received!

Your browser will disconnect because we call socket.end().

That's expected for now.


Step 4 - Complete the WebSocket handshake

Now comes the "magic".

The browser sends:

  • Sec-WebSocket-Key

The server must respond with:

  • Sec-WebSocket-Accept

Formula:

accept = base64(sha1(key + MAGIC_STRING))

Magic string (always the same):

258EAFA5-E914-47DA-95CA-C5AB0DC85B11

Update server.js:

const http = require("http");
const crypto = require("crypto");

const MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

const server = http.createServer((req, res) => {
  res.writeHead(200);
  res.end("Hello HTTP\n");
});

server.on("upgrade", (req, socket) => {
  const wsKey = req.headers["sec-websocket-key"];

  const acceptKey = crypto
    .createHash("sha1")
    .update(wsKey + MAGIC_STRING)
    .digest("base64");

  const responseHeaders = [
    "HTTP/1.1 101 Switching Protocols",
    "Upgrade: websocket",
    "Connection: Upgrade",
    `Sec-WebSocket-Accept: ${acceptKey}`,
    "\r\n",
  ];

  socket.write(responseHeaders.join("\r\n"));
  console.log("✅ WebSocket handshake done!");
});

server.listen(4000, () => {
  console.log("🚀 Server running on http://localhost:4000");
});
Enter fullscreen mode Exit fullscreen mode

Now open client.html again.

✅ The browser should connect successfully.

But if you send messages, nothing useful happens yet.

Because messages are not plain strings --- they are frames.


Step 5 - Listen for incoming frames

Once the handshake is done, WebSocket messages come as raw bytes.

Add this inside the upgrade handler (after socket.write(...)):

socket.on("data", (buffer) => {
console.log(buffer);
});

Now update your client to send a message:

socket.onopen = () => {
  console.log("✅ connected");
  socket.send("Hello server!");
};
Enter fullscreen mode Exit fullscreen mode

Run again.

You'll see a Buffer full of bytes.

That's a WebSocket frame.

Now we decode it.


Step 6 - Decode a WebSocket frame (client → server)

Add this helper function at the bottom of server.js:

function decodeWebSocketFrame(buffer) {
  const secondByte = buffer[1];
  const isMasked = (secondByte & 0b10000000) !== 0;
  let payloadLength = secondByte & 0b01111111;

  let offset = 2;

  if (payloadLength === 126) {
    payloadLength = buffer.readUInt16BE(offset);
    offset += 2;
  } else if (payloadLength === 127) {
    payloadLength = Number(buffer.readBigUInt64BE(offset));
    offset += 8;
  }

  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("utf8");
}`

Now update the `data` listener:

`socket.on("data", (buffer) => {
  const msg = decodeWebSocketFrame(buffer);
  console.log("📩 Message:", msg);
});
Enter fullscreen mode Exit fullscreen mode

✅ Now server logs the real message.


Step 7 - Send a message back (server → client)

Now we need to send a frame back.

Add this helper function:

function encodeWebSocketFrame(message) {
  const payload = Buffer.from(message);
  const payloadLength = payload.length;

  let frame;

  if (payloadLength < 126) {
    frame = Buffer.alloc(2 + payloadLength);
    frame[0] = 0b10000001; // FIN + text frame
    frame[1] = payloadLength; // server frames are NOT masked
    payload.copy(frame, 2);
  } else {
    throw new Error("Payload too large for this demo");
  }

  return frame;
}`

Now inside `socket.on("data")`:

`socket.on("data", (buffer) => {
  const msg = decodeWebSocketFrame(buffer);
  console.log("📩 Message:", msg);

  socket.write(encodeWebSocketFrame("Server got: " + msg));
});`

Update client:

`socket.onmessage = (e) => {
  console.log("📩 from server:", e.data);
};
Enter fullscreen mode Exit fullscreen mode

✅ Now you have full two-way communication.


Step 8 - Make the client interactive

Replace client.html with:

<!DOCTYPE html>
<html>
  <body>
    <h1>Pure WebSocket Client</h1>

    <input id="msg" placeholder="Type message..." />
    <button id="send">Send</button>

    <ul id="log"></ul>

    <script>  const log = (text) => {
        const li = document.createElement("li");
        li.textContent = text;
        document.getElementById("log").appendChild(li);
      };

      const socket = new WebSocket("ws://localhost:4000");

      socket.onopen = () => log("✅ Connected!");
      socket.onmessage = (e) => log("📩 Server: " + e.data);
      socket.onclose = () => log("❌ Disconnected");
      socket.onerror = () => log("⚠️ Error happened");

      document.getElementById("send").onclick = () => {
        const input = document.getElementById("msg");
        socket.send(input.value);
        log("📤 You: " + input.value);
        input.value = "";
      };
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

✅ Now you can type messages and see replies.


What This Teaches You (The Real Value)

This tutorial shows you what WebSocket libraries do for you:

  • handshake logic

  • frame decoding (client messages are masked)

  • frame encoding

  • keeping connections open

And also why libraries are used:

Because real WebSocket servers must handle:

  • fragmented frames

  • ping/pong keepalive

  • close frames

  • multiple frames per TCP chunk

  • partial frames split across chunks

  • binary payloads


Final Notes

This is not a replacement for ws or socket.io.

But if you build this once, you stop thinking of WebSockets as magic.

You understand the protocol.
And I think it's fun :D.

Top comments (0)