High-performance Chat with TypeScript, Websockets & Redis in 10min !

Meidi Airouche - Aug 6 - - Dev Community

The purpose of this article is to understand the basics of websockets and cache with a common known example : a Chat.

Usually, a Chat have to be really reactive with very good performance. There are many ways to achieve different levels of performance.

In this article, we'll build a basic sample high-performance Chat together with Typescript, Redis and Websockets. I'll guide you step-by-step as if I was doing it with you.

First, let's take a look at our architecture.

Architecture

Architecture

  1. To the keep things simple, we'll be doing the frontend in plain HTML/JS without framework
  2. The Nodejs server will be written with express as it's most known.
  3. A Redis server to cache messages

Tips : If you wanna enhance performance further, you can opt for another framework like fastify, websocket.io, uWebSocket or whatever you want. We'll stay with express as it's well commonly known.

Backend

Project structure

.
├── docker-compose.yml
├── Dockerfile
├── src
   ├── index.ts
   ├── server.ts
   ├── redisClient.ts
   └── types.ts
├── tsconfig.json
└── package.json
Enter fullscreen mode Exit fullscreen mode

Create the nodejs project

npm init
Enter fullscreen mode Exit fullscreen mode

and feed the package.json file with the following configuration :

{
  "name": "chat-app",
  "version": "1.0.0",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.17.1",
    "redis": "^4.0.0",
    "ws": "^8.0.0"
  },
  "devDependencies": {
    "@types/node": "^16.0.0",
    "@types/ws": "^8.0.0",
    "typescript": "^4.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Install dependencies into your project

npm install express ws redis 
npm install --save-dev typescript @types/node @types/ws @types/express @types/redis
Enter fullscreen mode Exit fullscreen mode

If not already done, don't forget to configure your tsconfig.json :

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Coding

Redis client configuration

Let’s create our Redis configuration in a dedicated file ./src/redisClient.ts. Here, we're connecting the client to Redis and we listen for errors to raise it in the console.

import { createClient } from 'redis';

const client = createClient({
  url: 'redis://redis:6379' // we'll create the docker image later
});

client.on('error', (err) => {
  console.error('Redis Client Error', err);
});

client.connect();

export default client;

Enter fullscreen mode Exit fullscreen mode

Message type or interface

We’ll be creating an interface (create a type if you prefer) to structure our Chat messages in ./src/chatMessage.ts. Let’s keep it simple and have only 3 attributes :

  • username: the name of the message author
  • message: the content of the message
  • timestamp: date and hour of the message as a timestamp
export interface ChatMessage {
  username: string;
  message: string;
  timestamp: number;
}
Enter fullscreen mode Exit fullscreen mode

Http & Websocket servers

Here, we’ll be creating two servers in ./src/server.ts :

  • HTTP server with express to handle http calls
  • Websocket server to handle websocket messages
import express from "express";
import { WebSocketServer } from "ws";
import redisClient from "./redisClient";
import { ChatMessage } from "./chatMessage";

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("Chat server is running");
});

const server = app.listen(port, () => {
  console.log(`Web server is running on http://localhost:${port}`);
});

const wss = new WebSocketServer({ server });

wss.on("connection", (ws) => {
  console.log("Client connected");

  // Send chat history to new client
  redisClient.lRange("chat_message", 0, -1).then(
    (messages) => {
      messages.forEach((message) => {
        ws.send(message);
      });
    },
    (error) => {
      console.error("Error retrieving messages from Redis", error);
      return;
    }
  );

  ws.on("message", (data) => {
    const message: ChatMessage = JSON.parse(data.toString());
    message.timestamp = Date.now();
    const messageString = JSON.stringify(message);

    // Save message to Redis
    redisClient.rPush("chat_messages", messageString);
    redisClient.lTrim("chat_messages", -100, -1); // Keep only the last 100 messages

    // Broadcast message to all clients
    wss.clients.forEach((client) => {
      if (client.readyState === ws.OPEN) {
        client.send(messageString);
      }
    });
  });

  ws.on("close", () => {
    console.log("Client disconnected");
  });
});

console.log("WebSocket server is running on ws://localhost:3000");
Enter fullscreen mode Exit fullscreen mode

So, what are we doing here :

  • First, we setup the Web and WebSocket servers.
  • Second, on the websocket connection, we pull the Chat history from redis
  • Third, we listen for new messages and record them in redis, keeping only the 100 last message anytime
  • Fourth, we broadcast each messages to all the connected clients of the Chat

If you wanna go further, you can implement an observer pattern where each websocket client is an observer. But for now, let's keep the things simple.

For barrels fans, don’t forget to add your app entrypoint in ./src/index.ts :

import './server';
Enter fullscreen mode Exit fullscreen mode

You can launch the server with :

npm run build
npm start
Enter fullscreen mode Exit fullscreen mode

Frontend

As we said it previously, we'll build a simple example which does not take account of best practices for the article. Create a single index.hml with the following code :

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chat</title>
</head>
<body>
  <div id="chat">
    <ul id="messages"></ul>
    <input id="username" placeholder="Username">
    <input id="message" placeholder="Message">
    <button onclick="sendMessage()">Send</button>
  </div>
  <script>
    const ws = new WebSocket('ws://localhost:3000');
    const messages = document.getElementById('messages');

    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      const li = document.createElement('li');
      li.textContent = `[${new Date(message.timestamp).toLocaleTimeString()}] ${message.username}: ${message.message}`;
      messages.appendChild(li);
    };

    function sendMessage() {
      const username = document.getElementById('username').value;
      const message = document.getElementById('message').value;
      ws.send(JSON.stringify({ username, message }));
    }
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

So what are we doing here :

  1. We setup the html document to have the needed fields and button to send a message
  2. We setup the websocket connection with the server
  3. We publish every message to the websocket anytime the button is pressed calling the function sendMessage()

All together with docker compose

Create a Dockerfile for the server

# Use official Nodejs image
FROM node:18

# Work directory
WORKDIR /usr/src/app

# Copy package.json & package-lock.json
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy sources files
COPY . .

# Compile Typescript
RUN npm run build

# Expose web server port
EXPOSE 3000

# Start the server
CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

Create a Docker compose file to assemble

Let's assemble the web server and Radis server inside a docker-compose.yml file.

version: '3.8'

services:
  redis:
    image: redis:latest
    container_name: redis
    ports:
      - "6379:6379"

  app:
    build: .
    container_name: chat-app
    ports:
      - "3000:3000"
    depends_on:
      - redis
    volumes:
      - .:/usr/src/app
    command: npm start
Enter fullscreen mode Exit fullscreen mode

Building and starting the whole stack

To build and run the whole backend stack with docker images, use :

docker-compose up --build
Enter fullscreen mode Exit fullscreen mode

Open your index.html file and start sending messages. We're done ! A high-performance chat with websocket and Redis !

Sources

Link : GitHub Repository

Hope it helps to understand basics. After that, you might be willing to take a look at Observer pattern and framworks like socket.io. Enjoy !

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .