Scaling WebSocket Connections using Shared Workers

You can find the code for this post on SharedWorker WebSocket example.

Web Sockets

Web Sockets allow real-time communication between the client browser and a server. They are different from HTTP because they not only allow client to request data from the server but also allow server to push data from the server.

image

The Problem

But in order to allow this each client needs to open a connection with the server and keep it alive till the time client closes the tab/goes offline. They create a persistent connection. This makes the interaction stateful, leading both client and server to store at least some data in memory on the WebSocket server for each open client connection.

So if a client has 15 tabs open, they’ll have 15 open connections to the server. This post is an attempted solution to try and reduce this load from a single client.

image

WebWorkers, SharedWorkers and BroadcastChannels to the rescue

Web Workers are a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. Once created, a worker can send messages to the JavaScript code that created it by posting messages to an event handler specified by that code (and vice versa).

Shared Workers are a type of web workers that can be accessed from several browsing contexts, such as several windows, iframes or even workers.

Broadcast Channels allows simple communication between browsing contexts (that is windows, tabs, frames, or iframes) with the same origin.

All the above definitions are from MDN.

Reducing the server load using SharedWorkers

We can use SharedWorker for solving this problem of a single client having multiple connections open from the same browser. Instead of opening a connection from each tab/browser window, we can instead use a SharedWorker to open the connection to the server.

This connection will be open until all the tabs to the website are closed. And the single connection can be used by all the open tabs to communicate with and receive messages from the server.

We’ll use the broadcast channels API to broadcast state change of the web socket to all the contexts (tabs).

Setting up a basic Web Socket Server

Let us now jump in the code. For the purpose of this post, we’ll set up a very simple web server that supports socket connections using the ws npm module. Initialize a npm project using:

$ npm init

Run through the steps, once you have a package.json file, add the ws module and express for a basic http server:

$ npm install --save ws express

Once you have this, create a index.js file with the following code to set up your static server serving files from public directory at port 3000 and running a ws server at port 3001:

const  express  =  require("express");
const  path  =  require("path");
const  WebSocket  =  require("ws");
const  app  =  express();

// Use the public directory for static file requests
app.use(express.static("public"));

// Start our WS server at 3001
const wss = new WebSocket.Server({ port: 3001 });

wss.on("connection", ws => {
  console.log('A new client connected!');
  ws.on("message", data => {
    console.log(`Message from client: ${data}`);

    // Modify the input and return the same.
    const  parsed  =  JSON.parse(data);
    ws.send(
      JSON.stringify({
        ...parsed.data,
        // Additional field set from the server using the from field.
        // We'll see how this is set in the next section.
        messageFromServer: `Hello tab id: ${parsed.data.from}`
      })
    );
  });
  ws.on("close", () => {
    console.log("Sad to see you go :(");
  });
});

// Listen for requests for static pages at 3000
const  server  =  app.listen(3000, function() {
  console.log("The server is running on http://localhost:"  +  3000);
});

Creating a SharedWorker

To create any type of a Worker in JavaScript, you need to create a separate file that defines what the worker will do.

Within the worker file, you need to define what to do when this worker is initialized. This code will only be called once when the SharedWorker is initialized. After that until the last tab connecting to this worker is not closed/ends connection with this worker, this code cannot be rerun.

We can define a onconnect event handler to handle each tab connecting to this SharedWorker. Let us look at the worker.js file.

// Open a connection. This is a common
// connection. This will be opened only once.
const ws = new WebSocket("ws://localhost:3001");

// Create a broadcast channel to notify about state changes
const broadcastChannel = new BroadcastChannel("WebSocketChannel");

// Mapping to keep track of ports. You can think of ports as
// mediums through we can communicate to and from tabs.
// This is a map from a uuid assigned to each context(tab)
// to its Port. This is needed because Port API does not have
// any identifier we can use to identify messages coming from it.
const  idToPortMap  = {};

// Let all connected contexts(tabs) know about state cahnges
ws.onopen = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });
ws.onclose = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });

// When we receive data from the server.
ws.onmessage  = ({ data }) => {
  console.log(data);
  // Construct object to be passed to handlers
  const parsedData = { data:  JSON.parse(data), type:  "message" }
  if (!parsedData.data.from) {
    // Broadcast to all contexts(tabs). This is because 
    // no particular id was set on the from field here. 
    // We're using this field to identify which tab sent
    // the message
    broadcastChannel.postMessage(parsedData);
  } else {
    // Get the port to post to using the uuid, ie send to
    // expected tab only.
    idToPortMap[parsedData.data.from].postMessage(parsedData);
  }
};

// Event handler called when a tab tries to connect to this worker.
onconnect = e => {
  // Get the MessagePort from the event. This will be the
  // communication channel between SharedWorker and the Tab
  const  port  =  e.ports[0];
  port.onmessage  =  msg  => {
    // Collect port information in the map
    idToPortMap[msg.data.from] =  port;
    
    // Forward this message to the ws connection.
    ws.send(JSON.stringify({ data:  msg.data }));
  };

  // We need this to notify the newly connected context to know
  // the current state of WS connection.
  port.postMessage({ state: ws.readyState, type: "WSState"});
};

There are a few things we’ve done here that may not be clear from the start. As you read through the post, these things will become clear as to why we did those. Still some points I want to clarify on:

  • We’re using the Broadcast Channel API to broadcast the state change of the socket.
  • We’re using postMessage to the port on connection to set the initial state of the context(tab).
  • We’re using the from field coming from the context(tabs) themselves to identify where to redirect the response.
  • In case we don’t have a from field set from the message coming from the server, we’ll just broadcast it to everyone!

Note: console.log statements here won’t work in your tab’s console. You need to open the SharedWorker console to be able to see those logs. To open the dev tools for SharedWorkers, head over to chrome://inspect.

Consuming a SharedWorker

Let us first create an HTML page to house our script that’ll consume the SharedWorker.

<!DOCTYPE  html>
<html  lang="en">
<head>
  <meta  charset="UTF-8"  />
  <title>Web Sockets</title>
</head>
<body>
  <script  src="https://cdnjs.cloudflare.com/ajax/libs/node-uuid/1.4.8/uuid.min.js"></script>
  <script  src="main.js"></script>
</body>
</html>

So we’ve defined our worker in worker.js file and set up a HTML Page. Now let us look at how we can use this shared web socket connection from any context(tab). Create a main.js file with the following contents:

// Create a SharedWorker Instance using the worker.js file. 
// You need this to be present in all JS files that want access to the socket
const worker = new SharedWorker("worker.js");

// Create a unique identifier using the uuid lib. This will help us
// in identifying the tab from which a message was sent. And if a 
// response is sent from server for this tab, we can redirect it using
// this id.
const id = uuid.v4();

// Set initial web socket state to connecting. We'll modify this based
// on events.
let  webSocketState  =  WebSocket.CONNECTING;
console.log(`Initializing the web worker for user: ${id}`);

// Connect to the shared worker
worker.port.start();

// Set an event listener that either sets state of the web socket
// Or handles data coming in for ONLY this tab.
worker.port.onmessage = event => {
  switch (event.data.type) {
    case "WSState":
      webSocketState = event.data.state;
      break;
    case "message":
      handleMessageFromPort(event.data);
      break;
  }
};

// Set up the broadcast channel to listen to web socket events.
// This is also similar to above handler. But the handler here is
// for events being broadcasted to all the tabs.
const broadcastChannel = new BroadcastChannel("WebSocketChannel");
broadcastChannel.addEventListener("message", event => {
  switch (event.data.type) {
    case  "WSState":
      webSocketState  =  event.data.state;
      break;
    case  "message":
      handleBroadcast(event.data);
      break;
  }
});

// Listen to broadcasts from server
function  handleBroadcast(data) {
  console.log("This message is meant for everyone!");
  console.log(data);
}

// Handle event only meant for this tab
function  handleMessageFromPort(data) {
  console.log(`This message is meant only for user with id: ${id}`);
  console.log(data);
}

// Use this method to send data to the server.
function  postMessageToWSServer(input) {
  if (webSocketState  ===  WebSocket.CONNECTING) {
    console.log("Still connecting to the server, try again later!");
  } else  if (
    webSocketState  ===  WebSocket.CLOSING  ||
    webSocketState  ===  WebSocket.CLOSED
  ) {
    console.log("Connection Closed!");
  } else {
    worker.port.postMessage({
      // Include the sender information as a uuid to get back the response
      from:  id,
      data:  input
    });
  }
}

// Sent a message to server after approx 2.5 sec. This will 
// give enough time to web socket connection to be created.
setTimeout(() =>  postMessageToWSServer("Initial message"), 2500);```

Sending Messages to SharedWorker

As we’ve seen above, you can send messages to this SharedWorker using worker.port.postMessage(). You can pass any JS object/array/primitive value here.

A good practice here can be passing an object that specifies from what context the message is coming so that the worker can take action accordingly. So for example, if we have a chat application and one of the tabs wants to send a message, we can use something like:

{
    // Define the type and the 
  type: 'message',
  from: 'Tab1'
  value: {
    text: 'Hello',
    createdAt: new Date()
  }
}

If we have a file sharing application, on deleting a file, the same structure can be used with a different type and value:

{
  type: 'deleteFile',
  from: 'Tab2'
  value: {
    fileName: 'a.txt',
    deletedBy: 'testUser'
  }
}

This will allow the Worker to decide what to do with it.

Listening to messages from the worker

We had set up a map in the beginning to keep track of MessagePorts of different tabs. We then set up a worker.port.onmessage event handler to handle events coming from the SharedWorker directly to the tab.

In cases where the server doesn’t set a from field, we just broadcast the message to all tabs using a broadcast channel. All tabs will have a message listener for the WebSocketChannel which will handle all message broadcasts.

This type of a set up can be used in following 2 scenarios:

  • Let’s say you’re playing a game from a tab. You only want the messages to come to this tab. Other tabs won’t be needing this information. This is where you can use the first case.
  • Now, if you were playing this game on facebook, and got a text message. This information should be broadcasted across all tabs as the notification count in the title would need to be updated.

Final Diagrammatic Representation

We’ve used SharedWorkers to optimize our use of Web Sockets. Here is the final diagrammatic representation of how this can be used:

image

Note

This is just an experiment I wanted to try to share the same socket connection across multiple browsing contexts. I think this can help reduce the number of connections needed per client. There are still a lot of rough edges around this. Let me know what you think about this solution to a scaling problem with realtime application. Repository containing the code: SharedWorker WebSocket example.

Written on November 3, 2019