An Introduction to WebSockets with Node.js and React

KarlBoghossian.com Introduction to WebSockets

In this tutorial I will go over a quick introduction to WebSockets, show you how you can setup a server that connects to clients/sockets, how you can identify the connected clients/sockets, and finally how to forward incoming messages to the appropriate client/socket.

For this tutorial, I’ll be sharing Examples on how to setup your server using Node.js. As for the client, I will be using React-Native. But you can pretty much translate the same concepts to the platform/language of your choice.

Ledger Manager Cover

Ledger Manager

Check out my iOS app which facilitates recording crypto transactions (Bitcoin, Ethereum, etc.), simplifies tax reporting & reports on overall net worth!

*Everything is synced privately on your iCloud account.
**Helps getting your crypto tax reporting in order (Form 8949) with smart calculation of cost basis & acquisition dates.

What are WebSockets?

WebSocket is a computer communications protocol, that makes it possible to open a two-way interactive communication session between a client (browser, app, etc.) and a server.

With the WebSocket APIs you can send messages to a server and receive event-driven responses without having to poll the server for a reply.

// client sends... this.socket.send(JSON.stringify({ type: 'welcome', data: 'Yay!' }))

// server receives... socket.on('message', data => { try { data = JSON.parse(data) } catch (err) { return socket.send(JSON.stringify({ error: true, message: data })) } console.log('Received Data: ', data) })

The way the communication is established between the client <> server using a “WebSocket Handshake” process:

  • The client sends a regular HTTP request over TCP/IP to the server.
  • An “upgrade” header is included in the request that informs the server that the client would like to establish a WebSocket connection.
  • If the server supports the WebSocket protocol, it performs the “upgrade” to conclude that handshake process.
  • That will replace the initial HTTP connection by a WebSocket connection, which uses the same TCP/IP concept.
  • At this point, both client and server can start sending messages back and forth.

With the recent years, this technology became more popular and a first-class citizen of many platforms.

React-Native has websockets built-in. Apple started to officially support it with the introduction of iOS 13. Node.js has multiple really good libraries out there, and the list goes on 😊.

WebSocket Concepts

When you configure your WebSocket instance on your client or server, there are basic methods that you need to define. First off, is instantiating the WebSocket singleton, then defining the connect, close, message, upgrade methods.

WebSockets URLs use the ws scheme instead of http and wss instead of https.

The client establishes a WebSocket connection through a process known as the WebSocket handshake. This process starts with the client sending a regular HTTP request to the server.

  • “connect” method gets triggered whenever a new client initiates the connection with the server.
  • “close” method gets triggered whenever a client disconnects from the server.
  • “message” method gets called whenever we receive a message from the socket/server, so that we can react to it.
  • “upgrade” method is used in this request that informs the server that the client wishes to establish a WebSocket connection. If the server supports the WebSocket protocol, it agrees to the upgrade and communicates this through an upgrade header in the response.

Supported Payload Types In WebSockets

When using WebSockets, you usually deal with strings, or in rare cases you can send the data in binary format.

Configuring the WebSocket Server on Node.js

'use strict'
const ws = require('ws')

const server = ... // your express app server instance.
const wsServer = new ws.Server({ noServer: true })

wsServer.on('connection', socket => {
    socket.on('message', data => {
      try {
        data = JSON.parse(data)
      } catch (err) {
        return socket.send(JSON.stringify({ error: true, message: data }))
      }

      const messageToSend = JSON.stringify({ error: false, message: data })

      wsServer.clients.forEach(function each(client) {
        // don't send to the server
        // and the original sender
        if (client !== wsServer &&
            client.readyState === ws.OPEN &&
            client !== socket) {
          client.send(messageToSend)
        }
      })
    })

    socket.on('close', function () {
      // the socket/client disconnected.
    })
})

server.on('upgrade', (request, socket, head) => {
  wsServer.handleUpgrade(request, socket, head, socket => {
    wsServer.emit('connection', socket, request)
  })
})

// hold a reference to our singleton
server.websocketServer = wsServer

What the code above is doing is the following:

  • We import the ‘ws’ npm library into our node.js file. This is a very popular and robust library with about 25M weekly downloads.
  • Create an instance of the websocket server.
  • Define the ‘connection’ method that gets triggered every time a new client connects to our ws server.
  • Define the ‘message’ method that gets triggered when a client sends a message to the websocket server url. As you can see in that method, we looped through all the connected clients, exclude the server and the initial sender, and forward all messages to the remaining clients!
  • Define the ‘close’ method that gets triggered when our client gets disconnected.’
  • Finally define the server ‘upgrade’ that informs the server that the client wishes to establish a WebSocket connection, then if agreed upon, we’ll trigger our ‘connection’ method!

💡As you can see, every message received is automatically converted from string to JSON using JSON.parse(). We then check for a proper format, or inject some other property, then we have to convert the JSON object back to a string.

You will notice how we’ll JSON.parse() on the client side to convert that string back to JSON to read it as an object.

Configuring the Client for WebSockets

I have my node.js express server running on localhost with port 3000, i.e.: http://localhost:3000.

For the client to connect to the websocket part of my server, I used this ws URL: ws://localhost:3000. As you can see, both of my express server and ws server are running on the same port. But the difference here is ws.

    // this.socket = new WebSocket('wss://echo.websocket.org/')
    this.socket = new WebSocket('ws://localhost:3000')

    // setup websocket
    this.socket.onopen = () => {
      console.log('>> WS OPENED ✅')
    }

    this.socket.onmessage = ({data}) => {
      console.log('>> WS MESSAGE: ', data)

      try {
        data = JSON.parse(data)

        // grab my custom ‘message’ property from the data.
        const { message } = data
        if (data.error !== true && message) {
          // Handle the ‘type’ and ‘data’ properties...
          // this.handleMessageType(message.type, message.data)
        } else {
          // do something with the incorrect format
        }
      } catch (err) {
        console.log(`⚠️ WEBSOCKET MESSAGE ERROR - ${err.message}`)
      }
    }

    this.socket.onerror = (error) => {
      console.log('>> WS ERROR: ', error.message)
    }

    this.socket.onclose = (error) => {
      console.log('>> WS CLOSED -', error.message)
    }

Same as the server side, we define the different methods that will get triggered upon connecting, disconnecting, receiving a message, etc. But we can call a handleMessage method that can do a switch case for example on the type property, then react to that message received!

💡When a message is received, you convert the payload received from string to JSON object using JSON.parse().

Identifying Clients on the Server

In that server code above, remember how every time we receive a message, we loop through all the connected clients and forward them the message?

wsServer.clients.forEach(function each(client) {
  // don't send to the server
  // and the original sender
  if (client !== wsServer &&
    client.readyState === ws.OPEN &&
    client !== socket) {
    client.send(messageToSend)
  }
})

What if depending on some server logic, we want to send a message to a specific client, without broadcasting that message to all connected clients? In that case, we need a way to identify those clients from the server.

If you recall, the way we established a connection from our client to the server is via ws://localhost:3000.

To create some unique identifier for our client, we can simply pass in a url parameter as such: ws://localhost:3000/my-ios-app or for another client set the ws url as: ws://localhost:3000/my-android-app or even ws://localhost:3000/<user_id>.

All that remains is to read that url parameter on the server side, and store it for future reference so that we can target the desired client:

var webSockets = {}

wsServer.on('connection', (socket, req) => {
    // grab the clientId from the url request i.e.: /my-ios-app
    var clientId = req.url.substr(1)
    // save it for later to allow us to target that client
    // if needed.
    webSockets[clientId] = socket

    socket.on('message', data => {
    .
    .
    . })

    socket.on('close', function () {
      delete webSockets[clientId]
    })
})

The change here is that instead of just defining the ‘connection’ method with a socket parameter, we also intercept the req parameter. We take out the / character and end up with whatever was passed in from the client (e.g.: ‘my-ios-app’ or ‘my-android-app’ or ‘<user_id>’).

We then store that under a new variable that I called webSockets which is a dictionary, with that clientId as the key.

Upon calling the ‘close’ method, we delete the reference to that socket as it’s no longer connected.

Sending a Message to a Single Client

Now that we have the connected clients stored in that webSockets dictionary, we’ll create a method that can send a payload to a specific client without “bothering” and “broadcasting” to all connected clients:

  // sends a message to a specific client
  wsServer.sendToClient = (clientId, type, data = {}) => {
    const payload = { type, data }
    const messageToSend = JSON.stringify({ error: false, message: payload })

    if (webSockets[clientId] &&
        webSockets[clientId].readyState === ws.OPEN) {
      webSockets[clientId].send(messageToSend)
    } else {
      throw new Error(`${clientId} websocket client is not connected.`)
    }
  }

This is a simple helper method that I added to my wsServer singleton. What is does is just check if that client does exist as a reference in my dictionary, it then checks if the connection is OPEN, if all is good, we send the message. As simple as that!

Reacting to Different Message Types

A clean approach I like to follow is to have my websocket implementation be categorized based on the message type you receive. The way I do it is by creating a new variable listeners that keeps track of all callback methods that are associated with a specific message type:

  var listeners = {}

  // adds a listener to get notified when receive a message
  wsServer.addListenerForType = (type, fn) => {
    if (!listeners[type]) {
      listeners[type] = []
    }
    listeners[type].push(fn)
  }

  // removes a listener
  wsServer.removeListenerForType = (type, fn) => {
    if (!listeners[type]) {
      return
    }

    var index = listeners[type].indexOf(fn)
    if (index > -1) {
      listeners[type].splice(index, 1)
    }
  }

  // removes all listeners for type
  wsServer.removeListenersForType = (type) => {
    listeners[type].splice(0, listeners[type].length)
  }

  // removes all the listeners
  wsServer.removeAllListeners = () => {
    for (key in listeners) {
      this.removeListenersForType(key)
    }
    listeners = {}
  }

Those are very basic util methods that just keep track of the callbacks used, organized by type. Usage would be as follows:

wsServer.addListenerForType (‘welcome’, (data) => {
  // do whatever you want here with the data...
})

We now have a callback method that will get triggered whenever we receive a message with a type property with ‘welcome’ as the value. But now you’re wondering, how is that working 🤨 — well, we’re not done, we still need to update our ‘message’ method to trigger all our listeners upon receiving a message:

    socket.on('message', data => {
      try {
        data = JSON.parse(data)
      } catch (err) {
        return socket.send(JSON.stringify({ error: true, message: data }))
      }

      const messageToSend = JSON.stringify({ error: false, message: data })

      wsServer.clients.forEach(function each(client) {
        // don't send to the server
        // and the original sender
        if (client !== wsServer &&
            client.readyState === ws.OPEN &&
            client !== socket) {
          client.send(messageToSend)
        }
      })

      // check if we have any listeners that are interested in that message
      if (listeners[data.type]) {
        // notify them all
        for (const listener of listeners[data.type]) {
          listener(data.data)
        }
      }

      .
      .

In the updated code above, every time we receive a message, we check if we have listeners for that message type, and if we do, we call them all and pass in whatever parameters you expect in your addListenerForType callback method format.

Feel free to use that same approach for the client-side implementation as well. It will make users of your websocket parts of your code more user-friendly and you won’t have to repeat boilerplate code everywhere.

Create a More Resilient WebSocket Connection

So far we’ve seen how we can connect a client to a websocket server, handle incoming messages and how to create listeners methods for a cleaner handling approach.

What happens when your client disconnects from your WebSocket server? Well, it will just trigger the ‘close’ callback on your client to let you know that the connection is closed, and the server will also “clean up” the webSockets dictionary as the socket is no longer opened.

A better approach is to have the client “retry” the connection on a timer. Here’s how I implemented this retry mechanism:

const CONNECTION_RETRY_INTERVAL = 5000 // in ms
const CONNECTION_RETRY_MAX_COUNT = 60 // 60 times to retry x 5s = 5min of total retries

  connect() {
    const self = this

    this.socket = new WebSocket('ws://localhost:3000/my-app')

    // setup websocket
    this.socket.onopen = () => {
      console.log('>> WS OPENED ✅')

      // if we got retries, it means we recovered.
      if (self.retryCount > 0) {
        // we recovered...
      }
      // reset the total retries
      self.retryCount = 0
    }

    this.socket.onerror = (error) => {
      if (!self.isRetrying()) {
        console.log('>> WS ERROR: ', error.message)
      }
    }

    this.socket.onclose = (error) => {
      // if we aren't retrying...
      if (!self.isRetrying()) {
        console.log('>> WS CLOSED -', error.message)
      }

      // if we're allowed to retry, let's do it.
      if (self.retryCount < CONNECTION_RETRY_MAX_COUNT) {
        setTimeout(function() {
          self.retryCount++
          self.connect()
        }, CONNECTION_RETRY_INTERVAL);
      } else {
        // we passed the threshold for retries, let's abort
        self.retryCount = 0
      }
    }
  }

  isRetrying () {
    return this.retryCount > 0
  }

The logic described above isn’t rocket science:

  • Whenever we ‘connect’ successfully, we reset the retry counter retryCount.
  • Whenever we receive an ‘error’ callback, we check if we’re currently isRetrying(), then we don’t broadcast that to the user, as we’re still trying to reconnect.
  • Whenever we receive the ‘close’ callback, we do the same as ‘error’, but we also check if we’re allowed to retry again, we do so by waiting for like 5s 5000 ms first, then we call our connect() method.

Another thing to note, is that if a client sends a message to the WebSocket server and is meant to go to another client, but that other client happens to not be currently connected to server, that message will be lost! There isn’t any mechanism for the server to wait until that “destination” client resumes the connection to send to it. Although it shouldn’t be very challenging to add that, but I didn’t need to cover that specific use case in my application. If you do end up implementing that, please feel free to share that in the comments section 😊.

Example of a Client-Side WebSocket Implementation

Here’s the full client-side implementation:

import {
  Alert,
  NativeModules,
  DeviceEventEmitter,
  ToastAndroid
} from 'react-native'

import * as actions from '../actions'
import { store } from '../redux/store'
import _ from 'lodash'

import Sentry from 'react-native-sentry'

const TYPE_ALL = '_ALL_'

const CONNECTION_RETRY_INTERVAL = 5000 // in ms
const CONNECTION_RETRY_MAX_COUNT = 60 // 60 times to retry x 5s = 5min of total retries

class WebSocketWrapper {

  constructor () {
    this.listeners = {}
    this.retryCount = 0

    this.connect()
  }

  // adds a listener to get notified when we connect
  addListenerForType (type, fn) {
    if (!this.listeners[type]) {
      this.listeners[type] = []
    }
    this.listeners[type].push(fn)
  }

  addListener (fn) {
    this.addListenerForType(TYPE_ALL, fn)
  }

  // removes a listener
  removeListenerForType (type, fn) {
    if (!this.listeners[type]) {
      return
    }

    var index = this.listeners[type].indexOf(fn)
    if (index > -1) {
      this.listeners[type].splice(index, 1)
    }
  }

  removeListener (fn) {
    this.removeListenerForType(TYPE_ALL, fn)
  }

  // removes all listeners for type
  removeListenersForType (type) {
    this.listeners[type].splice(0, this.listeners[type].length)
  }

  // removes all the listeners
  removeAllListeners () {
    for (key in this.listeners) {
      this.removeListenersForType(key)
    }
    this.listeners = {}
  }

  getStatus () {
    if (!this.socket) {
      return 'N/A'
    }

    switch (this.socket.readyState) {
      case this.socket.OPEN:
        return 'OPEN'
      case this.socket.CLOSED:
        return 'CLOSED'
      case this.socket.CLOSING:
        return 'CLOSING'
      case this.socket.CONNECTING:
        return 'CONNECTING'
    }
    return 'N/A'
  }

  isConnected () {
    return this.getStatus() === 'OPEN'
  }

  connect() {
    const self = this

    this.socket = new WebSocket('ws://localhost:3000/my-app')

    // setup websocket
    this.socket.onopen = () => {
      console.log('>> WS OPENED ✅')

      // if we got retries, it means we recovered.
      if (self.retryCount > 0) {
        // log to some analytics if needed...
      }
      // reset the total retries
      self.retryCount = 0
    }

    this.socket.onmessage = ({data}) => {
      console.log('>> WS MESSAGE: ', data)

      try {
        data = JSON.parse(data)

        const { message } = data
        if (data.error !== true && message) {
          self.handleMessageType(message.type, message.data)
        } else {
          // log to some analytics if needed...
        }
      } catch (err) {
        console.log(`⚠️ WEBSOCKET MESSAGE ERROR - ${err.message}`)
      }
    }

    this.socket.onerror = (error) => {
      if (!self.isRetrying()) {
        console.log('>> WS ERROR: ', error.message)
      }
    }

    this.socket.onclose = (error) => {
      // if we aren't retrying...
      if (!self.isRetrying()) {
        console.log('>> WS CLOSED -', error.message)
      }

      // if we're allowed to retry, let's do it.
      if (self.retryCount < CONNECTION_RETRY_MAX_COUNT) {
        setTimeout(function() {
          self.retryCount++

          self.connect()
        }, CONNECTION_RETRY_INTERVAL);
      } else {
        // we passed the threshold for retries, let's abort
        self.retryCount = 0
      }
    }
  }

  handleMessageType (type, data) {
    if (this.listeners[type]) {
      // notify all listeners that are interested in that event type
      for (const listener of this.listeners[type]) {
        listener(type, data)
      }
    }

    // also notify the listerers for the all situation
    if (this.listeners[TYPE_ALL]) {
      for (const listener of this.listeners[TYPE_ALL]) {
        listener(type, data)
      }
    }
  }

  // This is a test method to emit a generic message.
  emit() {
    if (this.isConnected()) {
      this.socket.send(JSON.stringify({ type: 'welcome', data: 'Yay!'}))
    }
  }

  isRetrying () {
    return this.retryCount > 0
  }
}

const mWebSocketWrapper = new WebSocketWrapper()
export default mWebSocketWrapper

Example of a Server-Side WebSocket Implementation

Here’s the full server-side implementation of websockets:

'use strict'

const ws = require('ws')
const Archetype = require('archetype')
const _ = require('lodash')

const WebSocketPayload = new Archetype({
  type: {
    $type: 'string',
    $required: true
  },
  data: {
    $type: Object,
    $default: () => ({})
  }
}).compile('WebSocketPayload')

var webSockets = {}
var listeners = {}

module.exports = function websocketServer (server) {
  const wsServer = new ws.Server({ noServer: true })

  wsServer.on('connection', (socket, req) => {
    // grab the clientId from the url request i.e.: /my-app
    var clientId = req.url.substr(1)
    // save it for later to allow us to target that client
    // if needed.
    webSockets[clientId] = socket

    socket.on('message', data => {
      try {
        data = new WebSocketPayload(JSON.parse(data))
      } catch (err) {
        return socket.send(JSON.stringify({ error: true, message: data }))
      }

      const messageToSend = JSON.stringify({ error: false, message: data })

      wsServer.clients.forEach(function each(client) {
        // don't send to the server
        // and the original sender
        if (client !== wsServer &&
            client.readyState === ws.OPEN &&
            client !== socket) {
          client.send(messageToSend)
        }
      })

      // check if we have any listeners that are interested in that message
      if (listeners[data.type]) {
        // notify them all
        for (const listener of listeners[data.type]) {
          listener(clientId, data.type, data.data)
        }
      }
    })

    socket.on('close', function () {
      delete webSockets[clientId]
    })
  })

  server.on('upgrade', (request, socket, head) => {
    wsServer.handleUpgrade(request, socket, head, socket => {
      wsServer.emit('connection', socket, request)
    })
  })

  // adds a listener to get notified when receive a message
  wsServer.addListenerForType = (type, fn) => {
    if (!listeners[type]) {
      listeners[type] = []
    }
    listeners[type].push(fn)
  }

  // removes a listener
  wsServer.removeListenerForType = (type, fn) => {
    if (!listeners[type]) {
      return
    }

    var index = listeners[type].indexOf(fn)
    if (index > -1) {
      listeners[type].splice(index, 1)
    }
  }

  // removes all listeners for type
  wsServer.removeListenersForType = (type) => {
    listeners[type].splice(0, listeners[type].length)
  }

  // removes all the listeners
  wsServer.removeAllListeners = () => {
    for (key in listeners) {
      this.removeListenersForType(key)
    }
    listeners = {}
  }

  // sends a message to a specific client
  wsServer.sendToClient = (clientId, type, data = {}) => {
    const payload = { type, data }
    const messageToSend = JSON.stringify({ error: false, message: payload })

    if (webSockets[clientId] &&
        webSockets[clientId].readyState === ws.OPEN) {
      webSockets[clientId].send(messageToSend)
    } else {
      throw new Error(`${clientId} websocket client is not connected.`)
    }
  }

  server.websocketServer = wsServer
}

Sending Images Between Sockets

A common scenario is to send images across the connection. An easy way I was able to do so is converting my image to base64, then sending that whole image as a string format!

I tested sending images, it was pretty fast and I was very pleased with the results.

Payload Size Limits

After doing some research, it appeared that you can send a LOT of data in a single websocket message:

A single WebSocket frame, per RFC-6455 base framing, has a maximum size limit of 2^63 bytes (9,223,372,036,854,775,807 bytes ~= 9.22 exabytes).

I think you should be good 😅.

I hope that introduction was extensive and covered a good chunk of the WebSocket concept, along with some tips on how to make your system more resilient. As always, please subscribe for more content 🍻.

Ledger Manager Cover

Ledger Manager

Check out my iOS app which facilitates recording crypto transactions (Bitcoin, Ethereum, etc.), simplifies tax reporting & reports on overall net worth!

*Everything is synced privately on your iCloud account.
**Helps getting your crypto tax reporting in order (Form 8949) with smart calculation of cost basis & acquisition dates.

66

Leave a Reply

Your email address will not be published. Required fields are marked *