We've all built a hobby real-time application using standard WebSockets. It works flawlessly on localhost with 3 active tabs.
But if you deploy that exact same single-instance architecture to production and hit it with 5,000 concurrent users, it crumbles. Connections drop, your single Node.js event loop chokes on database listeners, and your metrics charts completely freeze.
In this guide, we are going to move past the tutorials and build a production-ready, horizontally scalable real-time dashboard using Express, MongoDB Change Streams, Redis, and React.
---## 🏗️ The Problem with Naive Architectures
If a user updates data on API Server A, but your dashboard user is connected via WebSockets to API Server B, they will never see the live update.
To solve this, we must decouple database writes from data propagation and introduce a Message Broker (Redis).
Production Architecture Blueprint
┌─────────────────────────┐
│ NGINX / Load Balancer │
└────────────┬────────────┘
│
┌────────────────────────┴────────────────────────┐
│ HTTP (/api/*) │ WebSockets (/socket.io/*)
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Express API Server │ │ Socket.io Server Cluster │
│ (Handles DB Mutations) │ │ (Instance 1) (Instance 2) │
└──────────┬──────────────────┘ └──────────────┬──────────────┘
│ │
Writes │ │ Pub/Sub
▼ ▼
┌───────────────────────────────┐ ┌─────────────────────┐
│ MongoDB Atlas Cluster ├───────────────────►│ Redis Cluster │
│ (Primary / Replica Set) │ Change Streams │ (Message Broker) │
└───────────────────────────────┘ └─────────────────────┘
shell
---## 🛠️ Step 1: The Scalable Backend Engine
First, install the enterprise stack in your backend directory:
npm install express mongoose socket.io @socket.io/redis-adapter redis dotenv cors
1. The Socket.io Cluster Server (server.js)
This node is dedicated entirely to managing client WebSocket connections. It leverages a Redis adapter to synchronize client states across multiple distributed servers.
// backend/server.js
require('dotenv').config();
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const { createClient } = require('redis');
const { createAdapter } = require('@socket.io/redis-adapter');
const cors = require('cors');
const app = express();
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json());
const httpServer = createServer(app);
const pubClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log('📡 Connected to Redis Cluster Broker');
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_URL || 'http://localhost:5173', methods: ['GET', 'POST'] },
transports: ['websocket'] // Force WebSockets for lower overhead
});
io.adapter(createAdapter(pubClient, subClient));
// Middleware Guardrail: Validate JWT Token at handshake
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Authentication error'));
// Perform verification logic here...
next();
});
io.on('connection', (socket) => {
console.log(`🔌 Client connected: ${socket.id}`);
socket.join('live-metrics');
socket.on('disconnect', () => {
console.log(`❌ Client disconnected: ${socket.id}`);
});
});
// Dedicated Redis listener channel for cross-process communication
const nodeSubClient = pubClient.duplicate();
nodeSubClient.connect().then(() => {
nodeSubClient.subscribe('socket-io-bridge', (message) => {
const payload = JSON.parse(message);
io.to(payload.room).emit(payload.event, payload.data);
});
});
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => console.log(`🚀 WebSocket Engine active on port ${PORT}`));
}).catch(console.error);
2. The Isolated Database Worker (worker.js)
Production rule: Do not attach Change Stream listeners to your API request handlers. Run an isolated worker process.
We use an Aggregation Pipeline inside the Change Stream to ensure that MongoDB filters out unnecessary mutation noise (like simple profile updates) before it ever sends data over the network network to Node.js.
// backend/worker.js
require('dotenv').config();
const mongoose = require('mongoose');
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
const OrderSchema = new mongoose.Schema({
amount: Number,
status: String,
category: String
}, { timestamps: true });
const Order = mongoose.model('Order', OrderSchema);
async function startWorker() {
await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/dashboard?replicaSet=rs0');
await redisClient.connect();
console.log('👷 Background Change Stream Worker Engine Active');
// Aggregation Pipeline Pipeline Optimization
const pipeline = [
{
$match: {
operationType: { $in: ['insert', 'update'] },
'fullDocument.status': 'completed' // Only trigger on successful revenue events
}
},
{
$project: {
id: '$fullDocument._id',
revenue: '$fullDocument.amount',
category: '$fullDocument.category',
time: '$fullDocument.createdAt'
}
}
];
const changeStream = Order.watch(pipeline, { fullDocument: 'updateLookup' });
changeStream.on('change', (data) => {
const socketPayload = {
event: 'metrics-update',
room: 'live-metrics',
data: data.fullDocument
};
// Safely publish back to the Redis highway
redisClient.publish('socket-io-bridge', JSON.stringify(socketPayload));
});
}
startWorker().catch(console.error);
---## ⚡ Step 2: The Reactive Frontend UI
On the client side, we need a solution that cleanly hooks into the component lifecycle without leaving trailing ghost socket listeners that cause memory leaks.
npm install socket.io-client recharts
1. Abstracting with a Custom Hook (useLiveMetrics.js)
// frontend/src/hooks/useLiveMetrics.js
import { useEffect, useState } from 'react';
import { io } from 'socket.io-client';
const SOCKET_URL = import.meta.env.VITE_WS_URL || 'http://localhost:4000';
export const useLiveMetrics = (authToken) => {
const [data, setData] = useState([]);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socket = io(SOCKET_URL, {
transports: ['websocket'],
auth: { token: authToken },
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socket.on('metrics-update', (newMetric) => {
setData((prevData) => {
const updated = [...prevData, newMetric];
// Enforce a sliding render window constraint (Last 12 entries)
if (updated.length > 12) updated.shift();
return updated;
});
});
return () => {
socket.off('metrics-update');
socket.disconnect();
};
}, [authToken]);
return { data, isConnected };
};
2. Building the Visual Chart Component (Dashboard.jsx)
// frontend/src/components/Dashboard.jsx
import React from 'react';
import { useLiveMetrics } from '../hooks/useLiveMetrics';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
export default function Dashboard({ token }) {
const { data, isConnected } = useLiveMetrics(token);
return (
<div style={{ padding: '24px', background: '#0f172a', color: '#fff', minHeight: '100vh' }}>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<h2>Live System Metrics</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
width: '12px', height: '12px', borderRadius: '50%',
backgroundColor: isConnected ? '#10b981' : '#ef4444'
}} />
<span>{isConnected ? 'Syncing Live' : 'Offline'}</span>
</div>
</header>
<main style={{ marginTop: '32px', background: '#1e293b', padding: '24px', borderRadius: '12px' }}>
<div style={{ width: '100%', height: 350 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="time" stroke="#94a3b8" tickFormatter={(tick) => new Date(tick).toLocaleTimeString()} />
<YAxis stroke="#94a3b8" />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', borderColor: '#334155', color: '#fff' }} />
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" strokeWidth={3} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</div>
</main>
</div>
);
}
---## 🛡️ Production Checklist & Optimization Rules
Before pushing this architecture live, make sure your infrastructure meets these criteria:
- MongoDB Replica Set Mandatory: Change Streams watch the database oplog. This means they do not work on standalone MongoDB installations. You must run a replica set or utilize MongoDB Atlas.2. Sticky Sessions: If you ever allow Socket.io to fall back to HTTP Long-Polling (e.g., across restrictive corporate firewalls), you must enable sticky sessions on your NGINX or AWS Application Load Balancer.
Top comments (0)