Server in JS
WebSocket itself is a protocol that allows full-duplex and long-lived connections. Being full-duplex and event-based, it’s commonly used for real-time data streaming, replacing the old HTTP “polling”. A WebSocket server is often handled by a web server; that doesn’t mean it has to be tightly coupled in the implementation, but it’s because the WebSocket protocol strictly depends on HTTP to start the connection.
Here I’m going to implement a WebSocket server in vanilla JS.
Handshake
This article skips security concerns like TLS/SSL layers and all that stuff.
Web servers are, at the end of the day, a service running on a TCP socket. What is a socket? Well, it’s an operating system abstraction that represents a communication endpoint. I recommend reading a bit more in depth about the TCP handshake to better digest what comes next.
The first thing we need to establish a connection is an HTTP server capable of upgrading the connection to WebSocket:
const http = require("http");
const crypto = require("crypto"); // Needed later
const server = http.createServer();
...
// Handle server events
...
server.listen(8080, () =>
console.log("WebSocket server running on ws://localhost:8080")
);
It’s simple here: I created an HTTP server listening on port 8080. In this case I’m taking full advantage of Node.js event-based architecture; instead of defining a complete API to detect a certain action, we just listen to an event that the http module emits for us.
The client starts by showing its intention to switch protocols; it does this by sending an HTTP request with the necessary headers. In the HTTP request you should see:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
To which the server must respond with status 101 Switching Protocols to indicate that from that moment HTTP stops existing and WebSocket communication begins.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...
On the server side it would be handled like this:
server.on("upgrade", (req, socket) => {
// 1. Validate the upgrade header
if (req.headers["upgrade"] !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
// 2. Generate key
const key = req.headers["sec-websocket-key"];
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const acceptKey = crypto
.createHash("sha1")
.update(key + GUID)
.digest("base64");
// 3. Send handshake response
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${acceptKey}`,
"\r\n",
].join("\r\n")
);
// --- WebSocket connection established ---
// ... handle data
});
Connection and Upgrade
These two are the most important headers and they indicate the client’s desire to change protocol. If the server responds with the same headers, the requested protocol is supported and the connection is upgraded to the specified protocol.
Sec-WebSocket-*
The “Sec-” part makes it look like these headers are security-related, but in reality they’re not; they’re headers that ensure protocol integrity. In previous versions the handshake was different; doing it this way makes sure only servers that accept the latest version of WebSocket can respond. It also prevents our server (or proxy) from caching the response as if it were a normal HTTP request.
The most delicate header is “Sec-WebSocket-Accept”, which is generated by combining the “Sec-WebSocket-Key” sent by the client with the GUID and hashing it with SHA-1. The response would be:
Sec-WebSocket-Accept: base64(SHA1(key + GUID))
The GUID is literally a magic string defined in version 13 of the WebSocket protocol.
GUID: 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
Frame
In any protocol, the data unit has a specific name. In TCP they’re segments, in IP they’re packets, and in WebSocket they’re frames.
Servers that handle different application protocols know exactly what every bit received in a WebSocket frame means, thanks to the protocol standard (the contract that every client and server must follow to ensure communication).
| Byte | Bits | Field | Description |
|---|---|---|---|
| 0 | 7 | FIN | Highest bit (bit 7) |
| 0 | 6 | RSV1 | Usually 0 |
| 0 | 5 | RSV2 | Usually 0 |
| 0 | 4 | RSV3 | Usually 0 |
| 0 | 3–0 | Opcode | Frame type |
| 1 | 7 | MASK | If the payload comes from the client, always 1 |
| 1 | 6–0 | Payload length | Base length (0–125, or 126/127 for extended) |
| 2–5 | 0–31 | Masking key | Only if MASK=1 (4 bytes) |
| N… | — | Payload data | Actual (masked or not) data |
Knowing this table, you can use bitwise operators or binary masks. This is a critical point to understand how any protocol server works.
Data handling and testing
Knowing all the above, the only thing left is to listen to the “data” event to know when the client sent something and apply the corresponding operations.
socket.on("data", (buffer) => {
// Not all opcodes are handled here
const opcode = buffer[0] & 0b00001111;
if (opcode === 0x8) {
socket.end();
return;
}
const isMasked = buffer[1] & 0b10000000;
if (!isMasked) return; // All frames from the client must be masked
const payloadLength = buffer[1] & 0b01111111;
const mask = buffer.slice(2, 6);
const payload = buffer.slice(6, 6 + payloadLength);
// Unmask
const unmasked = Buffer.alloc(payloadLength);
for (let i = 0; i < payloadLength; i++) {
unmasked[i] = payload[i] ^ mask[i % 4];
}
const message = unmasked.toString();
console.log("Client says:", message);
// 5. Send response frame
const response = Buffer.from(message);
const header = Buffer.from([0x81, response.length]); // 0x81 = FIN + text opcode
socket.write(Buffer.concat([header, response]));
});
To test the server it’s easy: any modern browser supports the WebSocket API, which provides a simple way to connect to WebSocket servers. In this repo you can see a complete implementation and understand it in more depth.