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
WebSocketAPI-
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");
});
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: websocketConnection: 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");
});
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>
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");
});
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!");
};
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);
});
β 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);
};
β 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>
β 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)