← Back to list

Building a Real-Time Chat App in Express.js Using Soketi (Pusher-Compatible WebSockets)

A Step-by-Step Guide to Implementing Real-Time Communication through Pusher Channels

Anuj Jaryal · 2025-12-02 12:18 · 1 claps · 4.4 min read paywalled
#javascript #websocket #nodejs #soketi #pusher
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🔒 · Cybersecurity 📰 · Journalism & News

Building a Real-Time Chat App in Express.js Using Soketi (Pusher-Compatible WebSockets)

A Step-by-Step Guide to Implementing Real-Time Communication through Pusher Channels

Real-time applications — such as chat apps, live dashboards, presence systems, or collaborative tools — depend heavily on WebSockets. Soketi provides a blazing-fast, open-source alternative to Pusher and fully supports the Pusher Channels protocol, making it easy to integrate into any Node.js project. In this story, you’ll learn how to integrate real-time broadcasting with Soketi inside an Express.js application and build a fully working chat window with authentication, channels, and real-time message broadcasting.

Non members can read full-story here

1. Install Dependencies

Before getting started, install the required packages:

npm install pusher
npm install -g @soketi/soketi #soketi required Node js v18

2. Creating the Broadcasting Layer

We’ll implement a simple broadcasting system similar to Laravel Echo’s philosophy — using a broadcaster class, channel authorization handlers, and a unified BroadcastManager. Create following files in your project.

app/BroadcastManager.js

const PusherBroadcaster = require("./broadcasters/PusherBroadcaster");
const Pusher = require("pusher");

class BroadcastManager{

    static driver;
    static connection(){
        if(!this.driver){
            if(process.env.BROADCAST_DRIVER == 'pusher'){
                //console.log("here create driver");
                const pusher =  new Pusher({
                    appId: "app-id",
                    key: "app-key",
                    secret: "app-secret",
                    host : '127.0.0.1',
                    port : 6001,
                    scheme : 'http',
                    encrypted : true,
                    //useTLS : false,
                });
                this.driver = new PusherBroadcaster(pusher);
            }

        }else{
           // console.log("return default driver");
        }
        return this.driver;
    }
}

module.exports = BroadcastManager;

app/broadcasters/Broadcaster.js

const channels = require("../channels");

class Broadcaster{

    constructor(){
      this.channels = channels;
    }

    normalizeChannelName(channel){
        const prefixes = ['private-encrypted-', 'private-', 'presence-'];

        for (const prefix of prefixes) {
            if (channel.startsWith(prefix)) {
                return channel.substring(prefix.length);
            }

        }

        return channel;
    }

    isGuardedChannel=(channel) =>{
        const prefixes = ['private-', 'presence-'];

        return prefixes.some(prefix => channel.startsWith(prefix));
    }

    channelNameMatchesPattern = (channel, pattern) => {
        // Replace placeholders in the pattern (e.g., {id}) with a wildcard *
        const transformedPattern = pattern.replace(/\{(.*?)\}/g, '*');

        // Check if the channel matches the transformed pattern
        const regex = new RegExp(`^${transformedPattern}$`);

        return regex.test(channel);
    };

    extractParameters = (callback) =>{
        if (callback instanceof Function) {
          const paramNames = callback.toString().match(/\(([^)]*)\)/)[1].split(',').map(param => param.trim());
          return paramNames;
        }
          // not tested below
        // } else if (isString(callback)) {
        //   return this.extractParametersFromClass(callback);
        // }
        throw new Error('Invalid callback type');
    }

    extractChannelKeys = (pattern, channel) => {
        // Convert the pattern to a regular expression with named capturing groups
        const regexPattern = pattern.replace(/\{(.*?)\}/g, '(?<$1>[^\\.]+)');

        // Create the regular expression
        const regex = new RegExp(`^${regexPattern}$`);

        // Match the channel against the pattern
        const match = channel.match(regex);

        if (!match) {
          return null;
        }

        // Return the captured keys
        return match.groups;
    };
}

module.exports = Broadcaster;

app/broadcasters/PusherBroadcaster.js

const Broadcaster = require("./Broadcaster");

class PusherBroadcaster extends Broadcaster{

    pusher;

    constructor(pusher){
        super();
        this.pusher = pusher;
    }

    async broadcast(channels, payload){
        const response = await this.pusher.trigger(channels, payload.event, payload.data, {socket_id: payload.socket});
      console.log(response);
        if(typeof response == 'object' && response.status >= 200 && response.status <= 299 || response === true){

            return;
        }

        throw new Error(response.body ? `Pusher Error : ${response.body}`:'Failed to connect to Pusher.');
    }

    async auth(req, res){
        req.user = {id:1,name:"Alex"};
        let channelName = this.normalizeChannelName(req.body.channel_name);

        if(this.isGuardedChannel(req.body.channel_name) && !req.user){
            return res.status(403).json({error:"Not authorizied"});
        }

        for(let pattern in this.channels){
            if (!this.channelNameMatchesPattern(channelName, pattern)) {
                continue;
            }
            const callback = this.channels[pattern];

            const callbackParameters = this.extractParameters(callback);
            let channelKeys = this.extractChannelKeys(pattern, channelName);

            const args = callbackParameters.map(paramName => {
                if(paramName == "user"){
                    return req.user;
                }
                if(!channelKeys){
                    return null;
                }

                return channelKeys[paramName] || null;
            });
            let channelResult = await callback(...args);
            if(channelResult && channelResult !== false){
                let response = this.validAuthenticationResponse(req, channelResult);
                return res.status(200).json(response);
            }

        }
        return res.status(403).json({error:"Can't access the channel"})


    }

    validAuthenticationResponse(req, result){
        if(req.body.channel_name.startsWith('private')) {
            console.log("in private channel");
            return this.decodePusherResponse(req, this.pusher.authorizeChannel(req.body.socket_id, req.body.channel_name));
        }
        let channelName = this.normalizeChannelName(req.body.channel_name);

        return this.decodePusherResponse(
            req, 
            this.pusher.authorizeChannel(req.body.socket_id, req.body.channel_name, {user_id: req.user.id, user_info: result})
        )
    }

    decodePusherResponse(req, pusherRes){
        //console.log(pusherRes);
        if(!req.body.callback){
            return pusherRes;
        }
    }

}

module.exports = PusherBroadcaster;

app/channels.js Define channels and authorization:

module.exports = {
    "conversation.{id}": async(user, id)=>{
        return true;
    }
}

3. Create routes

var express = require("express");
var router = express.Router();

const BroadcastManager = require("../../app/broadcasting/BroadcastManager");

router.post("/broadcasting/auth", async function (req, res, next) {
  console.log(req.body);
  BroadcastManager.connection();
  const broadcaster = BroadcastManager.connection();
  return broadcaster.auth(req, res);
});

router.post("/chat", async function (req, res, next) {
  const broadcaster = BroadcastManager.connection();
  let a = await broadcaster.broadcast([req.body.conversation_channel], {
    event: "App\\Events\\Chat\\MessageRecived",
    data: {username:req.body.username, message:req.body.message},
    socket: req.headers["x-socket-id"],// include socket id if you want to broadcast to others, else omit it to broadcast to all
  });
  console.log(a);
  return res.json({});
});

module.exports = router;

4. Frontend Chat UI (HTML + Alpine.js + Echo + Tailwind)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Simple Chat</title>

    <!-- Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/pusher/8.4.0/pusher.min.js" integrity="sha512-p3rR75Is6DCK1r2D8mdxLQhe4IWVDSTUBdxqs0Veum0hHDSY+sH9M6U6Cesr1umlxbiEK9w/3IhXFlZcWT1AoA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <!-- Alpine.js CDN -->
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

    <!-- Axios CDN -->
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>

<body class="bg-gray-100 min-h-screen flex items-center justify-center">

<div x-data="chatApp()" class="w-full max-w-lg bg-white shadow-lg rounded p-6">

    <!-- Join Chat Screen -->
    <div x-show="!joined" class="space-y-4">
        <h1 class="text-2xl font-bold text-center">Join Chat</h1>

        <!-- Name Input -->
        <div>
            <input type="text"
                x-model="username"
                :class="{'border-red-500': nameError}"
                class="w-full border rounded px-3 py-2"
                placeholder="Enter your name">

            <p x-show="nameError" class="text-red-600 text-sm mt-1">
                Name is required.
            </p>
        </div>

        <button @click="joinChat"
                class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
            Join Chat
        </button>
    </div>

    <!-- Chat Screen -->
    <div x-show="joined" x-transition class="flex flex-col h-[500px]">
        <h2 class="text-xl font-bold mb-3">Chat Room</h2>

        <!-- Message Area -->
        <div class="flex-1 border rounded p-3 overflow-y-auto space-y-2" id="chat-box">
            <template x-for="msg in messages">
                <div class="p-2 rounded bg-gray-200" >
                    <span x-text="msg.username"></span>:
                    <span x-text="msg.message"></span>
                </div>
            </template>
        </div>

        <!-- Input -->
        <div class="mt-4 flex gap-2">
            <input type="text" x-model="input"
                   @keydown.enter="sendMessage"
                   class="flex-1 border rounded px-3 py-2"
                   placeholder="Type a message...">

            <button @click="sendMessage"
                    class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
                Send
            </button>
        </div>
    </div>

</div>
<script type="module">
import Echo from 'https://cdnjs.cloudflare.com/ajax/libs/laravel-echo/2.2.4/echo.js'
window.Echo = new Echo({
    broadcaster: 'pusher',
    key: 'app-key',
    wsHost: "127.0.0.1",
    wsPort: 6001,
    wssPort: 6001,
    forceTLS: false,
    encrypted: true,
    disableStats: true,
    enabledTransports: ['ws', 'wss'],
    cluster: "mt1"
});
</script>
<script>

function chatApp() {
    return {
        joined: false,
        input: "",
        messages: [],
        username: "",
        nameError: false,
        joinChat() {
            if (!this.username.trim()) {
                this.nameError = true;
                return;
            }
            // Add your join click handler here
            this.nameError = false;
            this.joined = true;
            window.Echo.private('conversation.1')
     .listen('Chat.MessageRecived', (e) => {
      this.messages.push(e);
     })

        },

        async sendMessage() {
            if (!this.input.trim()) return;

            const userMessage = this.input;
            this.messages.push({username: "You", message: userMessage});
            this.input = "";

            try {
                const res = await axios.post("/chat", { username:this.username,message: userMessage,conversation_channel: "private-conversation.1"});
            } catch (err) {
            }
        }
    }
}
</script>

</body>
</html>

Now run your application server and soketi server using soketi start . Visit you application server address . you will see following output.


메타데이터
post_id
185acb2ca5f3
slug
building-a-real-time-chat-app-in-express-js-using-soketi-pusher-compatible-websockets-185acb2ca5f3
url
https://medium.com/@anuj_jaryal/building-a-real-time-chat-app-in-express-js-using-soketi-pusher-compatible-websockets-185acb2ca5f3
canonical_url
https://medium.com/@anuj_jaryal/building-a-real-time-chat-app-in-express-js-using-soketi-pusher-compatible-websockets-185acb2ca5f3
author_url
https://medium.com/@anuj_jaryal
status
ok
fetched_at
2026-07-14 15:40:45