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