Matthew Tyson
Contributing writer

WebSockets under the hood with Node.js

how-to
09 Oct 20248 mins
JavaScriptNode.jsSoftware Development

A simple full-stack JavaScript application lets you see what happens under the hood in the WebSockets communication protocol.

A person stands looking under the hood of a car.
Credit: Shutterstock / UfaBizPhoto

WebSockets is a network communication protocol that enables two-way client-server communication. WebSockets are often used for applications that require instantaneous updates, using a persistent duplex channel atop HTTP to support real-time interactions without constant connection negotiation. Server push is one of the many popular use cases for WebSockets.

This article takes a code-first look at both sides of the WebSockets equation in JavaScript, using Node.js on the server and vanilla JavaScript in the browser.

The WebSocket protocol

Once upon a time, duplex communication or server push over HTTP, in the browser, required considerable trickery. These days, WebSockets is an official part of HTTP. It works as an “upgrade” connection to the normal HTTP connection.

WebSockets let you send arbitrary data back and forth between the browser client and your back end. Either side can initiate new messages, so you have the infrastructure for a wide range of real-time applications that require ongoing communication or broadcast. Developers use the WebSockets protocol for games, chat apps, live streaming, collaborative apps, and more. The possibilities are endless.

For the purpose of this article, we’ll create a simple server and client, then use them to look under the hood at what happens during a WebSockets communication.

Create a simple server

To start, you’ll need a /server directory with two subdirectories in it, /client and /server. Once you have those, you’ll need a very simple Node server that establishes a WebSocket connection and echoes back whatever is sent to it. Next, move into the /websockets/server and start a new project:


$ npm init

Next we need the ws project, which we’ll use for WebSocket support:


$ npm install ws

With that in place, we can sketch out a simple echo server, in echo.js:


// echo.js
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', (ws) => {
  console.log('Client connected');

  ws.on('message', (message) => {
    console.log('Received message:', message);   

    ws.send(message); // Echo the message back to the client
  });

  ws.on('close', () => {
    console.log('Client disconnected');
  });
});

console.log(‘server started’);

Here, we’re listening on port 3000 and then listening for the connection event on the WebSocket.server object. Once a connection occurs, we get the socket object (ws) as an argument to the callback. Using that, we listen for two more events: message and close.

Any time the client sends a message, it calls the onMessage handler and passes us the message. Inside that handler, we us the ws.send() method to send the echo response. Notice that ws.send() also lets us send messages whenever we need to, so we could push an update to the client based on some other event, like an update from a service or a message from another client.

The onClose handler lets us do work when the client disconnects. In this case, we just log it.

Test the socket server

It would be nice to have a simple way to test the socket server from the command line, and the Websocat tool is great for that purpose. It's a simple installation procedure, as described here, and there are many examples for using it.

Now start the server:


/websockets/server $ node echo.js

Background it with Ctrl-z and $ bg, then run the following:


$ ./websocat.x86_64-unknown-linux-musl -t --ws-c-uri=wss://localhost:3000/ - ws-c:cmd:'socat - ssl:echo.websocket.org:443,verify=0'

That will establish an open WebSocket connection that lets you type into the console and see responses. You’ll get an interaction like so:


$ node echo.js 
Server started
^Z
[1]+  Stopped                 node echo.js
matthewcarltyson@dev3:~/websockets/server$ bg
[1]+ node echo.js &
matthewcarltyson@dev3:~/websockets/server$ ./websocat.x86_64-unknown-linux-musl -t --ws-c-uri=wss://localhost:3000/ - ws-c:cmd:'socat - ssl:echo.websocket.org:443,verify=0'
Request served by 7811941c69e658
An echo test
An echo test
Works
Works
^C
matthewcarltyson@dev3:~/websockets/server$ fg
node echo.js
^C

Create the client

Now, let’s move into the /websockets/client directory and create a webpage we can use to interact with the server. Keep the server running in the background and we’ll access it from the client.

First, create an index.html file like so:


// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Client</title>
</head>
<body>
    <h1>WebSocket Client</h1>
    <input type="text" id="message" placeholder="Enter message">
    <button id="send-btn">Send</button>
    <div id="output"></div>
    <script src="script.js"></script>
</body>
</html>

This just provides a text input and a button to submit it. It doesn’t do anything itself, just provides the DOM elements we’ll need in the script file that we include:


// script.js
const wsUri = "ws://localhost:3000";
const outputDiv = document.getElementById("output");
const messageInput = document.getElementById("message");
const sendButton = document.getElementById("send-btn");

let websocket;

function connect() {
    websocket = new WebSocket(wsUri);

    websocket.onopen = function (event) {
        outputDiv.innerHTML += "

Connected to server!

"; }; websocket.onmessage = function (event) { const receivedMessage = event.data; outputDiv.innerHTML += "

Received: " + receivedMessage + "

"; }; websocket.onerror = function (event) { outputDiv.innerHTML += "

Error: " + event.error + "

"; }; websocket.onclose = function (event) { outputDiv.innerHTML += "

Connection closed.

"; }; } sendButton.addEventListener("click", function () { const message = messageInput.value; if (websocket && websocket.readyState === WebSocket.OPEN) { websocket.send(message); messageInput.value = ""; } else { outputDiv.innerHTML += "

Error: Connection not open.

"; } }); connect(); // Connect immediately

This script sets up several event handlers using the browser-native API. We start up the WebSocket as soon as the script is loaded and watch for open, onclose, onmessage, and onerror events. Each one appends its updates to the DOM. The most important one is onmessage, where we accept the message from the server and display it.

The Click handler on the button itself takes the input typed in by the user (messageInput.value) and uses the WebSocket object to send it to the server with the send() function. Then we reset the value of the input to a blank string.

Assuming the back end is still running and available at ws://localhost:3000, we can now run the front end. We can use http-server as a simple way to run the front end. It’s a simple way to host static files in a web server, akin to Python’s http module or Java’s Simple Web Server, but for Node. It can be installed as a global NPM package or simply run with npx, from the client directory:


/websockets/client/ $ npx http-server -o

When we run the previous command and visit the page, we get the form as we should. But when we enter a message in the input and hit Send, it says:


Received: [object Blob]

If you look in the browser dev console, everything is going over the WebSocket channel (the ws tab within the network tab). The question is, why is it coming back as a blob?

If you look at the server console, it says:


Client connected
Received message: <Buffer 6f 6d 20 6d 61 6e 69 20 70 61 64 6d 65 20 68 75 6d>

So now we know the problem is on the server. The problem is that a more recent version of the ws module doesn't automatically decode the message into strings, and instead just gives you the binary buffer. It’s a quick fix in the echo.js onmessage handler:


ws.on('message', (message, isBinary) => {
  message = isBinary ? message : message.toString();
  console.log('Received message:', message);   
  ws.send(message); 
});

We use the second parameter to the callback, isBinary, and if the handler is receiving a string, we do a quick conversion to a string using message.toString().

Conclusion

This quick tour has illuminated the underlying mechanisms of a WebSocket client-server communication without any obfuscation from frameworks. As you've seen, the basics of using WebSockets advanced capabilities are straightforward. Just using simple callbacks and message sending gives you full duplex and asynchronous communication using a browser-standard API and a popular Node library.

In many projects, of course, you’d want to use something like React on the front end and Node or a similar runtime on the back end. Fortunately, once you know the basics, these frameworks are easy to integrate.

The discussion and examples here intentionally glossed over security, which like every area of web development adds a layer of complexity to be managed on both sides of the stack. Scalability and error handling round out the additional issues we'd need to address in a real-world WebSockets implementation.

Exit mobile version