Dead Simple Chat offers prebuilt Chat that can be added in seconds to any app or website. Chat with powerful JavaScript Chat API and SDK
In this blog post, we will build a highly scalable chat application using WebSocket Node.JS and Redis.
This chat application will be high-performance and can handle many concurrent users. Let's get started.
What is Node.JS Cluster?
Node.JS is high-performance javascript runtime, but it is primarily single-threaded.
That means if you have a computer with multiple CPU cores, which all modern computers have your Node.JS application will not be able to leverage all the available CPU cores.
The Node.JS will by default launch a single process that will run on a single CPU core. If you have a 10-core CPU, then the remaining 9 cores will be utilized by your Node.JS program.
The Node.JS cluster module is a built-in Node.JS module and it aims to solve exactly this problem. The cluster module allows you to launch multiple child processes or worker threads that would execute on other CPU cores to fully utilize the multiple cores offered in modern CPUs.
We will use the Node.JS Cluster module to launch multiple threads to handle concurrent connections to our chat server to extract maximum performance.
What are WebSockets?
Websockets allow us to have a real-time connection between the client which is the browser and our backend chat server.
This the client to receive the messages in real time without polling the server, and it allows the server to push the messages to the client.
It creates a bi-directional pipe between the client and the server which will allow us to send and receive the messages.
To handle WebSockets we will use the "Socket.IO" library which offers a nice wrapper around the vanilla WebSockets and provides additional functionality like stick connections which will be much needed in our clustered application.
Why use Redis to store messages?
Redis is a high-performance in-memory data store, and it is super fast. It can handle thousands of operations per second on modest hardware.
Thus using Redis to store messages would add minimal lag to our Chat Message Server.
Step 1: Scaffolding the Application
Let's scaffold the application, we will create a directory named chat-application
mkdir chat-application
Then we will cd
into the directory.
cd chat-application
And we will run the command npm init
npm init -y
The npm init
command will create a package.json
file.
Also, create a folder called as public
it will contain our chat front-end.
mkdir public
Step 2: Installing Dependencies
For our chat application, we will install the following packages:
- express - Express is a minimal web application framework that will allow us to easily create a web app in Node.JS
- ioredis - ioredis is a high-performance redis driver for Node.JS. This will allow us to connect to the redis database from our Node.JS application.
- socket.io - socket.io is a WebSocket library that we will be using as it offers built-in sticky connection support for clustering.
- @socket.io/sticky and @socket.io/cluster-adapter - These two packages will be used to setup stick connections in the worker threads.
Run the following command to install the above dependencies
npm install express ioredis socket.io @socket.io/cluster-adapter @socket.io/sticky --save
Step 3: Clustering Setup
Create a file called as server.js
this will contain all the backend code for our chat server and open the server.js
file in your favourite code editor.
Add the following code to the server.js
file:
const cluster = require('node:cluster');
const http = require('node:http');
const numCPUs = require('node:os').availableParallelism();
const process = require('node:process');
const { setupMaster, setupWorker } = require("@socket.io/sticky");
const { createAdapter, setupPrimary } = require("@socket.io/cluster-adapter");
const { Server } = require("socket.io");
const { info } = require('node:console');
const express = require("express");
/**
* Checking if the thread is a worker thread
* or primary thread.
*/
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
/**
* Creating http-server for the master.
* All the child workers will share the same port (3000)
*/
const httpServer = http.createServer();
httpServer.listen(3000);
// Setting up stick session
setupMaster(httpServer, {
loadBalancingMethod: "least-connection"
});
// Setting up communication between workers and primary
setupPrimary();
cluster.setupPrimary({
serialization: "advanced"
});
// Launching workers based on the number of CPU threads.
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
/**
* Setting up the worker threads
*/
console.log(`Worker ${process.pid} started`);
/**
* Creating Express App and Socket.io Server
* and binding them to HTTP Server.
*/
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer);
// Using the cluster socket.io adapter.
io.adapter(createAdapter());
// Setting up worker connection with the primary thread.
setupWorker(io);
io.on("connection", (socket) => {
// Handling socket connections.
socket.on("message", (data) => {
console.log(`Message arrived at ${process.pid}`);
});
});
// Handle HTTP Requests
app.get("/", (req, res) => {
res.send("Hello world");
});
}
In the above code snippet we have created a socket.io and express server that runs on multiple cores.
It checks the number of CPUs available and launches the worker threads equal to the number of CPU threads available.
Step 4: Handling Messages
Each time a new client connects to the chat server the io.on("connection", (socket) => { });
code block executes.
io.on("connection", (socket) => {
});
The socket
object allows us to communicate bi-directionally with the client, we can listen to the event emitted by the client and we can also emit events to the client.
io.on("connection", (socket) => {
// Handling socket connections.
socket.on("message", (data) => {
console.log(`Message arrived at ${process.pid}:`, data);
socket.emit("message", data);
});
});
In the above code block, we are listening to the message
event, the name of the event can be anything.
And then we are emitting a message event to the client with some data. Data can be anything, it can be a string or JSON object.
Sending Messages to all the client
The socket.emit
event just sends the message to on client, the client the socket object is associated with.
If you want to send messages to all the connected client, then you can use the method io.broadcast.emit("event", message)
The io.broadcast.emit
method will send the message to all the connected clients.
If you want to send the message to all clients except the current socket instance, then you can use the method socket.broadcast.emit("event", message);
Build a basic chat message server
A very basic chat message server would be a chat message server that sends a message to all connected clients when it receives a new message.
The code for that will look this:
io.on("connection", (socket) => {
// Handling socket connections.
socket.on("message", (data) => {
console.log(`Message arrived at ${process.pid}:`, data);
io.broadcast.emit("message", data);
});
});
We will certainly improve this, by adding validation to the messages, sanitizing the messages and storing them in our Redis database, but apart from that the design of the chat message server is almost complete.
With just a few lines of code, we have built a highly scaleable chat server. Now let's build the front-end and then after that, we will come back to adding persistent storage for the messages.
Step 5: Building the Front-end
Remember we had created a public
folder. We will tell express to serve all the files in the public folder as static assets. To do that we will add the following line of code.
app.use(express.static('public'))
Then in the public folder create a file called index.html
In the public/index.html
file add the following code:
Now start the server and go to http://localhost:3000
and you will a page with "Hello world" text.
We will build our front-end inside the public folder. In the public folder create two more folders one is js
and css
.
The js
folder will contain the JavaScript code for the front-end and css
folder will contain the CSS.
Scaffolding the Front-End
We will delete everything from the index.html
file and create a basic HTML structure and import some dependencies like the socket.io client to connect to our websocket server and TailwindCSS for styling.
Step 6: Building the Chat UI
Now once the front end is scaffolded let's build the front-end chat UI, we will design basic chat UI using TailwindCSS and our final code looks something like this:
<!DOCTYPE html>
<html lang="en" data-theme="business">
<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>Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="grid h-screen">
<div class="flex max-h-screen flex-col">
<div class="header text-md border-b border-gray-200 py-4 pl-4 font-bold">Chat</div>
<div class="chat-messages flex-1 overflow-y-scroll">
<ul class="space-y-4">
<li>
<div class="flex space-x-2 pl-2 pt-2">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-indigo-400 flex items-center justify-center font-bold text-white">L</div>
</div>
<div class="flex flex-col">
<div class="flex items-baseline space-x-2">
<div class="font-bold">Lorenzon</div>
<div class="text-sm text-gray-400">5:20 pm</div>
</div>
<div class="text-sm text-gray-500">Lorem ipsum dolor sit amet, consectetur adipisicing elit. Eveniet est ipsa cupiditate dolorem! Libero aliquam aut soluta incidunt aspernatur error voluptatibus provident tenetur eaque, voluptatum, quam dolor nemo suscipit qui!</div>
</div>
</div>
</li>
</ul>
</div>
<div class="input-container p-4">
<div class="input-area flex h-28 w-full flex-col space-y-2 rounded-2xl border-2 border-gray-200 p-3">
<textarea class="w-full flex-1 outline-none"></textarea>
<div class="buttons flex flex-row items-center space-x-2 align-baseline">
<div class="flex-1">
</div>
<div>
<button class="float-right rounded-xl bg-indigo-500 p-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="white" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5 text-indigo-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/main.js"></script>
</body>
</html>
And the Chat UI will look like this:
Wiring up the UI
Adding IDs to connect to the UI elements
- We will update the HTML and id
chat-messages
to theul
tag that store the messages. - Add id
message
to thetextarea
element that will contain the typed message - Add id
send-message
to the send button
And we will remove the placeholder message from the html, and our index.html
file will look like this:
<!DOCTYPE html>
<html lang="en" data-theme="business">
<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>Chat</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="grid h-screen">
<div class="flex max-h-screen flex-col">
<div class="header text-md border-b border-gray-200 py-4 pl-4 font-bold">Chat</div>
<div class="chat-messages flex-1 overflow-y-scroll">
<ul class="space-y-4" id="chat-messages">
</ul>
</div>
<div class="input-container p-4">
<div class="input-area flex h-28 w-full flex-col space-y-2 rounded-2xl border-2 border-gray-200 p-3">
<textarea id="message" class="w-full flex-1 outline-none"></textarea>
<div class="buttons flex flex-row items-center space-x-2 align-baseline">
<div class="flex-1">
</div>
<div>
<button id="send-message" class="float-right rounded-xl bg-indigo-500 p-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="white" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-5 w-5 text-indigo-600">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/main.js"></script>
</body>
</html>
Connecting to the UI elements
Now we will connect the UI elements to our JavaScript code, open main.js
file and add the following code:
Let's go through above code, in the first line we are connecting to our WebSocket server.
const socket = io();
Then we are listening for a message event, and when the message event arrives are calling the method generateMessageHTML
method, and we will build that method in the next section.
socket.on("message", function(data) {
const html = generateMessageHTML(data.username, data.timestamp, data.message);
const element = document.createElement("li");
element.innerHTML = html;
document.getElementById("chat-messages").appendChild(element);
});
The generateMessageHTML
method returns the HTML for the message that we can append to the page.
Then we are creating an li
tag and adding the HTML to the li
tag, and appending the li
tag to chat-messages
.
Next, we are listening to the click event on the send-message
button.
document.getElementById("send-message").addEventListener("click", function() {
const message = document.getElementById("message").value;
socket.emit("message", {
username: "david",
message: message,
timestamp: new Date()
});
document.getElementById("message").value = "";
});
When the button is clicked we are emitting a message
event, that contains our chat message object.
The chat message object has the following properties:
{
"username": "<username of the user sending the message>",
"message": "<actual chat message>",
"timestamp": "<timestamp when the message was sent>"
}
For now, we are hardcoding the username as "David", but we will add the UI to input the username later.
And when the message is sent, we are clearing the text area.
document.getElementById("message").value = "";
generateMessageHTML
Method
The generateMessageHTML
method accepts the username
, timestamp
and message
parameters and generate the HTML for the message.
Here we are adding the html that was inside our li
tag and replacing the hardcoded values with variables.
function generateMessageHTML(username, timestamp, message) {
let formattedTimestamp;
if (new Date().toLocaleDateString() === new Date(timestamp).toLocaleDateString()) {
// Same day, include only time
formattedTimestamp = new Date(timestamp).toLocaleTimeString();
} else {
// Not the same day, include date and time
formattedTimestamp = new Date(timestamp).toLocaleString();
}
const html = `
<div class="flex space-x-2 pl-2 pt-2">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-indigo-400 flex items-center justify-center font-bold text-white">
${username.charAt(0).toUpperCase()}
</div>
</div>
<div class="flex flex-col">
<div class="flex items-baseline space-x-2">
<div class="font-bold">
${username.charAt(0).toUpperCase() + username.slice(1)}
</div>
<div class="text-sm text-gray-400">
${formattedTimestamp}
</div>
</div>
<div class="text-sm text-gray-500">
${message}
</div>
</div>
</div>
`
return html;
}
The code is very straightforward, first, we are formatting the timestamp. We are checking if the message is sent today or not.
If the message is sent today then we are just showing the time, and if the message was not sent today then we are showing both the date and time.
For the profile picture, we are extracting the first text for the user's username and capitalizing it.
We are also doing some formatting on the username, we are capitalizing the first letter of the username.
Then we are returning the html string, that we have seen gets appended to the DOM.
Step 7: Adding Persistence and Historical Messages
As you can see from the above video, our chat works but it does not persist messages, and also there is no way to load historical messages.
When the user refreshes the page all the previous messages disappear, and when a new user joins, the user cannot see the old messages.
To solve this problem, we will store all the messages sent into our Redis database, and when a new user joins the chat room, we will send the historical messages to the user.
Importing Redis
In the top of the server.js
file add the following lines:
// Importing redis client
// By default the redis client connects to redis instance running at localhost:6379
const Redis = require("ioredis");
const redisClient = new Redis();
These two lines will import the ioredis
client and connect to the locally running Redis server.
Installing Redis
If you don't have redis running locally, you can quickly launch a redis instance using docker. Install docker and then run the following commands:
docker pull redis
docker run -d -p 6379:6379 --name redis redis
Storing Messages in Redis
Then in the socket.on("message")..
code block add the following code to store the messages in the redis database.
redisClient.lpush("chat_messages", JSON.stringify(data));
Here is the full code snippet
io.on("connection", (socket) => {
// Handling socket connections.
socket.on("message", (data) => {
console.log(`Message arrived at ${process.pid}:`, data);
redisClient.lpush("chat_messages", JSON.stringify(data));
io.emit("message", data);
});
});
Sending Historical Messages
Now to send all the messages stored in Redis we will retrieve all the messages stored under the chat_messages
list and send it when the client first connects.
io.on("connection", async (socket) => {
// Fetching all the messages from redis
const existingMessages = await redisClient.lrange("chat_messages", 0, -1);
// Parsing the messages to JSON
const parsedMessages = existingMessages.map((item) => JSON.parse(item));
// Sending all the messages to the user
socket.emit("historical_messages", parsedMessages);
....
Receive Historical messages
On the front-end we need to listen to the historical_messages
event, and then loop through all the messages and append them to the chat-messages
list.
The code inside the for loop
will be same as the code when we receive a single new message.
socket.on("historical_messages", function(messages) {
for (let message of messages) {
const html = generateMessageHTML(message.username, message.timestamp, message.message);
const element = document.createElement("li");
element.innerHTML = html;
document.getElementById("chat-messages").appendChild(element);
}
});
As you can see in the demo above, the chat messages persist even after refreshing the page.
Step 8: Adding Ability to Enter Username
So far in our chat application, we have hard-coded the username. We will change that now and add the ability for the user to enter their own username.
We will hide the chat user interface and just show the user a dialogue to enter their username to join.
When the user types in their username, and press the join button we will save the username in a variable and show the chat interface.
Join user dialogue
The design for the join user dialogue is very simple, we will have just a text field and a "Join Chat" button.
The user will type in the username in the text field and when the "Join Chat" button is pressed we will hide the Join Chat dialogue and show the chat user interface.
Here is the HTML for the Join Chat Dialog:
<div id="join-chat-interface" class="w-screen h-screen flex items-center justify-center">
<div class="w-96 h-48 p-4 rounded-md border-2 border-indigo-500 shadow-lg shadow-blue-400/50">
<h1 class="text-xl font-bold">Join Chat</h1>
<hr class="my-2" />
<input id="username" class="rounded-md h-8 border border-gray-400 px-2" placeholder="Enter username" type="text" />
<button id="join-chat" class="block mt-4 text-white rounded-md font-bold px-4 py-2 bg-blue-500 shadow-lg shadow-blue-500/50 hover:bg-blue-600 hover:shadow-xl hover:shadow-blue-700/50">
Join Chat
</button>
</div>
</div>
Here is the complete index.html
file:
Accepting the username
In the above HTML code you can see that we have added id join-chat-interface
to the "Join Chat" UI and we have added the id chat-interface
to the chat room UI.
We will add a click listener to the "Join Chat" button and remove the class hidden
from the chat-interface
and add it to the join-chat-interface
div.
let USERNAME = "";
document.getElementById("join-chat").addEventListener("click", function() {
const username = document.getElementById("username").value;
if (username) {
USERNAME = username;
document.getElementById("chat-interface").classList.remove("hidden");
document.getElementById("join-chat-interface").classList.add("hidden");
} else {
alert("Please type in a username");
}
});
In the above code snippet we have created a global variable named USERNAME
.
When the join chat button is clicked we are checking the value of the username text field.
If the username text field is not blank, then we are assigning the value in the textfield to the global USERNAME
variable, and removing the hidden
class from the chat-interface
and adding the hidden
class to the join-chat-interface
.
And then in the send-message
click event listener we updated the hardcoded text "david"
with USERNAME
.
socket.emit("message", {
username: USERNAME,
message: message,
timestamp: new Date()
});
Here are some of our other articles that are a good read:
Here are the Markdown-formatted links corresponding to the article headings you've specified:
- Real Time Chat with React and Socket.IO
- Socket.io and NodeJS Chat Application Tutorial
- How to build a Discord Clone using Dead Simple Chat
- Golang Websocket chat server
Step 9: That's It! and Improvements
We have built a highly scaleable chat application, that leverages multiple CPU cores.
But this chat app has limitations and improvements:
- There is no proper authentication mechanism, as you have observed we are just strong the username as a string and passing it with the message.
- There is no validation and sanitization of the messages. The message should be validated and sanitized before sending otherwise it poses severe cross-browser scripting security issue.
- We are sending all the messages at once, there should be pagination through an infinite scroll
- Missing modern chat features like emojis, file sharing, image sharing, reply to messages.
- No way to moderate the chats.
You can also use Dead Simple Chat which is a highly scaleable and secure pre-built chat platform. It offers features like:
- Moderation
- Secure Single Sign On
- Reply to messages
- Private and 1-to-1 Chats
- Advanced Analytics and more,
Complete Source Code
Here is the complete source code of the chat application that we have built.
package.json
server.js
public/js/main.js
public/index.html