15 min readUpdated Mar 6, 2023

WebSockets and Node.js - testing WS and SockJS by building a web app

WebSockets and Node.js - testing WS and SockJS by building a web app
Jo FranchettiJo Franchetti

This post will look at implementing a couple of low-level WebSockets libraries with Node.js as the WebSocket server. We’ll look at how each library is used, and why you might choose it for your project. Then we’ll talk about the reasons one might choose a third-party service to manage their WebSockets connections.

WebSockets and Node.js

WebSockets let developers build realtime functionality into their apps by enabling the sending off small chunks of data over a single persistent connection, in both directions. Using WebSockets in the front end is fairly straightforward, as there is a WebSocket API built into all modern browsers. To use them on the server, a backend application is required.

This is where Node.js comes in. Node.js can maintain many hundreds of WebSockets connections simultaneously. WebSockets on the server can become complicated as the connection upgrade from HTTP to WebSockets requires handling. This is why developers commonly use a library to manage this for them. There are a few common WebSocket server libraries that make managing WebSockets easier – notably WS, SockJS and Socket.IO.

Download the report

WS – A Node.js WebSocket library

WS is a WebSockets server for Node.js. It's quite low level: you listen to incoming connection requests and respond to raw messages as either strings or byte buffers. Since WebSockets are natively supported in all modern browsers, it is possible to work with WS on the server and the browser's WebSocket API on the client.

In order to demonstrate how to set up WebSockets with Node and WS, we have built a demo app which shares users' cursor positions in realtime. We walk through building it below.

Building an interactive cursor position-sharing demo with WS

This is a demo to create a colored cursor icon for every connected user. When they move their mouse around, their cursor icon moves on the screen and is also shown as moving on the screen of every connected user. This happens in realtime, as the mouse is being moved.

The WebSockets Server

First, require the WS library and use the WebSocket.Server method to create a new WebSocket server on port 7071 (no significance, any port is fine!).

Note: For brevity’s sake we call it wss in our code. Any resemblance to WebSocket Secure (often referred to as WSS) is a coincidence.

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 7071 });

Next, create a Map to store a client's metadata (any data we wish to associate with a WebSocket client):

const clients = new Map();

Subscribe to the WSS connection event using the wss.on function, providing a callback. This will be called whenever a new WebSocket client connects to the server:

wss.on('connection', (ws) => {
    const id = uuidv4();
    const color = Math.floor(Math.random() * 360);
    const metadata = { id, color };

    clients.set(ws, metadata);

Every time a client connects, we generate a new unique ID, which is used to identify them. Clients are also assigned a cursor color by using Math.random(); this generates a number between 0 and 360, which corresponds to the hue value of an HSV color. The ID and cursor color are then added to an object that we'll call metadata, and we're using the Map to associate them with our ws WebSocket instance.

The map is a dictionary – we can retrieve this metadata by calling get and providing a WebSocket instance later on.

Using the newly connected WebSocket instance, we subscribe to that instance's message event, and provide a callback function that will be triggered whenever this specific client sends a message to the server.

   ws.on('message', (messageAsString) => {

Note: This event is on the WebSocket instance (ws) itself, and not on the WebSocketServer instance (wss).

Whenever our server receives a message, we use JSON.parse to get the message contents, and load our client metadata for this socket from our Map using clients.get(ws).

We're going to add our two metadata properties to the message as sender and color...

      const message = JSON.parse(messageAsString);
      const metadata = clients.get(ws);

      message.sender = metadata.id;
      message.color = metadata.color;

Then we stringify our message again, and send it out to every connected client.

     const outbound = JSON.stringify(message);

      [...clients.keys()].forEach((client) => {
        client.send(outbound);
      });
    });

Finally, when a client closes its connection, we remove its metadata from our Map.

   ws.on("close", () => {
      clients.delete(ws);
    });
});

At the bottom we have a function to generate a unique ID.

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
console.log("wss up");

This server implementation multicasts, sending any message it has received to all connected clients.

We now need to write some client-side code to connect to the WebSocket server, and transmit the user’s cursor position as it moves.

WebSockets on the client side

We're going to start with some standard HTML5 boilerplate:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>

Next we add a reference to a style sheet, and an index.js file that we're adding as an ES Module (using type="module").

    <link rel="stylesheet" href="style.css">
    <script src="index.js" type="module"></script>
</head>

The body contains a single HTML template which contains an SVG image of a pointer. We're going to use JavaScript to clone this template whenever a new user connects to our server.

<body id="box">
    <template id="cursor">
        <svg viewBox="0 0 16.3 24.7" class="cursor">
            <path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M15.6 15.6L.6.6v20.5l4.6-4.5 3.2 7.5 3.4-1.3-3-7.2z" />
        </svg>
    </template>
</body>
</html>

Next, we need to use JavaScript to connect to the WebSocket Server.

(async function() {
    const ws = await connectToServer();
     ...

We call the connectToServer() function, which resolves a promise containing the connected WebSocket.

(The function definition will be written later.)

Once connected, we add a handler for onmousemove to the document.body. The messageBody is very simple: it consists of the current clientX and clientY properties from the mouse movement event (the horizontal and vertical coordinates of the cursor within the application's viewport).

We stringify this object, and send it down our now connected ws WebSocket instance as the message text:

   document.body.onmousemove = (evt) => {
        const messageBody = { x: evt.clientX, y: evt.clientY };
        ws.send(JSON.stringify(messageBody));
    };

Now we need to add another handler, this time for an onmessage event to the WebSocket instance ws. Remember that every time the WebSocketServer receives a message, it'll forward it to all connected clients.

You might notice that the syntax here is slightly different from the server-side WebSocket code. That's because we're using the browser’s native WebSocket class, rather than the npm library ws.

   ws.onmessage = (webSocketMessage) => {
        const messageBody = JSON.parse(webSocketMessage.data);
        const cursor = getOrCreateCursorFor(messageBody);
        cursor.style.transform = `translate(${messageBody.x}px, ${messageBody.y}px)`;
    };

When we receive a message over the WebSocket, we parse the data property of the message, which contains the stringified data that the onmousemove handler sent to the WebSocketServer, along with the additional sender and color properties that the server side code adds to the message.

Using the parsed messageBody, we call getOrCreateCursorFor. This function returns an HTML element that is part of the DOM, and we'll look at how it works later.

We then use the x and y values from the messageBody to adjust the cursor position using a CSS transform.

Our code relies on two utility functions. The first is connectToServer which opens a connection to our WebSocketServer and then returns a Promise that resolves when the WebSockets readystate property is 1 - CONNECTED.

This means that we can just await this function, and we'll know that we have a connected and working WebSocket connection.

   async function connectToServer() {
        const ws = new WebSocket('ws://localhost:7071/ws');
        return new Promise((resolve, reject) => {
            const timer = setInterval(() => {
                if(ws.readyState === 1) {
                    clearInterval(timer)
                    resolve(ws);
                }
            }, 10);
        });
    }

We also use our getOrCreateCursorFor function.

This function first attempts to find any existing element with the HTML data attribute data-sender where the value is the same as the sender property in our message. If it finds one, we know that we've already created a cursor for this user, and we just need to return it so the calling code can adjust its position.

   function getOrCreateCursorFor(messageBody) {
        const sender = messageBody.sender;
        const existing = document.querySelector(`[data-sender='${sender}']`);
        if (existing) {
            return existing;
        }

If we can't find an existing element, we clone our HTML template, add the data-attribute with the current sender ID to it, and append it to the document.body before returning it.

        const template = document.getElementById('cursor');
        const cursor = template.content.firstElementChild.cloneNode(true);
        const svgPath = cursor.getElementsByTagName('path')[0];

        cursor.setAttribute("data-sender", sender);
        svgPath.setAttribute('fill', `hsl(${messageBody.color}, 50%, 50%)`);
        document.body.appendChild(cursor);

        return cursor;
    }

})();

Now when you run the web application, each user viewing the page will have a cursor that appears on everyone's screens because we are sending the data to all the clients using WebSockets.

Running the demo

If you’ve been following along with the tutorial, then you can run:

> npm install
> npm run start

If not, you can clone a working version of the demo

> git clone https://github.com/ably-labs/WebSockets-cursor-sharing.git
> npm install
> npm run start

This demo includes two applications: a web app that we serve through Snowpack, and a Node.js web server. The NPM start task will spin up both the API and the web server.

This should look as follows:

with-websockets-edited-1

However, if you are running the demo in a browser that does not support WebSockets (eg IE9 or below), or if you are restricted by particularly tight corporate proxies, you will get an error saying that the browser can’t establish a connection:

Image showing the error message when a WebSockets connection can’t be established

This is because the WS library offers no fallback transfer protocols if WebSockets are unavailable. If this is a requirement for your project, or you want to have a higher level of reliability of delivery for your messages, then you will need a library that offers multiple transfer protocols, such as SockJS.

SockJS – A JavaScript library to provide WebSocket-like communication between the client and server

SockJS is a library that mimics the native WebSockets API. Additionally, it will fall back to HTTP whenever a WebSocket fails to connect, or if the browser being used doesn’t support WebSockets. Like WS, SockJS requires a server counterpart; its maintainers provide both a JavaScript client library and a Node.js server library.

Using SockJS in the client is similar to the native WebSockets API, with a few small differences. We can swap out WS in the demo built previously and use SockJS instead to include fallback support.

Updating the Interactive Cursor Position Sharing Demo to use SockJS

To use SockJS in the client, we first need to load the SockJS JavaScript library from their CDN. In the head of the index.html document we built earlier, add the following line above the script include of index.js:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/sockjs.min.js" defer></script>

Note the defer keyword – it ensures that the SockJS library is loaded before index.js runs.

In the app/script.js file, we then update the JavaScript to use SockJS. Instead of the WebSocket object, we’ll now use a SockJS object. Inside the connectToServer function, we’ll establish the connection with the SockJS server:

const ws = new SockJS('http://localhost:7071/ws');

Note: SockJS requires a prefix path on the server URL. The rest of the app/script.js file requires no change.

Now to update the API/script.js file to make our server use SockJS. This means changing the names of a few event hooks, but the API is very similar to WS.

First, we need to install sockjs-node. In your terminal run:

> npm install sockjs

Then we need to require the sockjs module and the built-in HTTP module from Node. Delete the line that requires ws and replace it with the following:

const http = require('http');
const sockjs = require('sockjs');

We then change the declaration of wss to become:

const wss = sockjs.createServer();

At the very bottom of the API/index.js file we’ll create the HTTPS server and add the SockJS HTTP handlers: \

const server = http.createServer();
wss.installHandlers(server, {prefix: '/ws'});
server.listen(7071, '0.0.0.0');

We map the handlers to a prefix supplied in a configuration object ('/ws'). We tell the HTTP server to listen on port 7071 (arbitrarily chosen) on all the network interfaces on the machine.

The final job is to update the event names to work with SockJS:

ws.on('message',                      will become      ws.on('data',
client.send(outbound);          will become      client.write(outbound);

And that’s it, the demo will now run with WebSockets where they are supported; and where they aren’t, it will degrade to use comet long polling over HTTP. This latter fallback option will show a slightly less smooth cursor movement, but it is more functional than no connection at all!

Running the demo

If you’ve been following along with the tutorial, then you can run:

> npm install
> npm run start

If not, you can clone a working version of the demo:

> git clone -b sockjs https://github.com/ably-labs/WebSockets-cursor-sharing.git
> npm install
> npm run start

This demo includes two applications: a web app that we serve through Snowpack, and a Node.js web server. The NPM start task spins up both the API and the web server.

without-websockets-edited

Does this scale?

You might notice that in both examples we're storing the state in the Node.js WebSocketServer – there is a Map that keeps track of connected WebSockets and their associated metadata. This means that for the solution to work, and for every user to see one another, they have to be connected to the exact same WebSocketServer.

The number of active users you can support is thus directly related to how much hardware your server has. Node.js is pretty good at managing concurrency, but once you reach a few hundred to a few thousand users, you're going to need to scale your hardware vertically to keep all the users in sync.

Scaling vertically is often an expensive proposition, and you'll always be faced with a performance ceiling of the most powerful piece of hardware you can procure. (It’s also not elastic and you have to do it ahead of time.) Once you've run out of vertical scaling options, you'll be forced to consider horizontal scaling – and horizontally scaling WebSockets is significantly more difficult.

What makes WebSockets hard to scale?

To scale regular application servers that don't require persistent connections, the standard approach is to introduce a load balancer in front of them. Load balancers route traffic to whichever node is currently available (either by measuring node performance, or using a round-robin system).

WebSockets are fundamentally harder to scale, because connections to your WebSocketServer need to be persistent. And even once you've scaled out your WebSocketServer nodes both vertically and horizontally, you also need to provide a solution for sharing data between the nodes. Any state needs to be stored out-of-process – this usually involves using something like Redis, or a traditional database, to ensure that all the nodes have the same view of state.

In addition to having to share state using additional technology, broadcasting to all subscribed clients becomes difficult, because any given WebSocketServer node knows only about the clients connected to itself.

There are multiple ways to solve this: either by using some form of direct connection between the cluster nodes that are handling the traffic, or by using an external pub/sub mechanism (like Redis). This is sometimes called "adding a backplane" to your infrastructure, and is yet another moving part that makes scaling WebSockets difficult.

WebSockets in Node.js perform very well, but growing them becomes more and more difficult as your traffic profiles increase. This is where Ably comes in!

Take our APIs for a spin


Using Ably to Scale WebSockets

If you’re building an app that needs reliable, scalable realtime data exchange, then a third-party platform could be the answer you’re looking for. Ably handles all the infrastructure and hard engineering challenges for you, such as dependably scaling WebSockets, while significantly reducing the development load on your team. Ably provides fallback protocols, and load balancing for you, as well as having global coverage, meaning that region-by-region scale is possible.

In addition, Ably comes with features like:

  • Connection IDs out of the box – no need to assign our own UUID in the demo above
  • Easy creation and management of multiple stateful channels to publish and receive data
  • Presence data to provide notification of who else is connected
  • Automatic reconnection if a device momentarily drops offline
  • History and rewind to receive missed messages when a connection is lost

All of these are extra factors have to be hand-rolled by a development team working with a low-level library like WS or SockJS. It all requires specialized engineering knowledge, making the proposition both expensive and time-consuming. Curious to find out more? Take our APIs for a spin.

Resources

The WebSocket Handbook

Join the Ably newsletter today

1000s of industry pioneers trust Ably for monthly insights on the realtime data economy.
Enter your email