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)