WebSocket Server in Vanilla JS


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).

ByteBitsFieldDescription
07FINHighest bit (bit 7)
06RSV1Usually 0
05RSV2Usually 0
04RSV3Usually 0
03–0OpcodeFrame type
17MASKIf the payload comes from the client, always 1
16–0Payload lengthBase length (0–125, or 126/127 for extended)
2–50–31Masking keyOnly if MASK=1 (4 bytes)
N…Payload dataActual (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.