What is a Real-Time Dashboard?
A real-time dashboard is a visual interface that displays data as it arrives — without requiring the user to refresh the page or poll the server. It updates charts, metrics, and status indicators the moment new information becomes available. This is achieved through a persistent connection between the client (browser) and the server, using WebSocket technology, combined with a charting library like Chart.js to render the data beautifully.
Think of it as a live heartbeat monitor for your application: stock tickers, server CPU usage, live user counts, IoT sensor readings, or any streaming data source that benefits from immediate visual feedback.
Why WebSocket + Chart.js Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Traditional HTTP polling — where the client repeatedly asks "any new data?" — is inefficient. It burns bandwidth, introduces latency, and doesn't scale well. WebSocket flips this model: the server pushes data to the client the instant it's available, over a single, lightweight TCP connection.
Pairing WebSocket with Chart.js gives you:
- Zero-lag updates — data appears as soon as the server emits it
- Reduced server load — no constant polling loops, fewer HTTP overheads
- Smooth animations — Chart.js transitions gracefully between old and new values
- Bidirectional communication — the client can also send commands back to the server (e.g., "change data source")
- Scalability — one WebSocket server can handle thousands of concurrent clients efficiently
How the Pieces Fit Together
Here's the architecture at a glance:
┌─────────────────────┐ WebSocket ┌─────────────────────┐
│ Data Source │◄──────────────────────►│ Browser Client │
│ (Node.js Server) │ (ws://localhost:8080) │ (Chart.js + HTML) │
│ │ │ │
│ - Generates data │ │ - Listens for msgs │
│ - Broadcasts to │ │ - Updates chart │
│ all clients │ │ - Renders in real │
│ │ │ time │
└─────────────────────┘ └─────────────────────┘
Part 1: Setting Up the WebSocket Server (Node.js)
We'll use the ws library — a fast, well-maintained WebSocket implementation for Node.js. Create a new project folder and install the dependencies:
mkdir realtime-dashboard
cd realtime-dashboard
npm init -y
npm install ws chart.js
Now create the server file server.js. This server will generate random data every second and broadcast it to all connected clients:
// server.js
const WebSocket = require('ws');
const PORT = 8080;
const wss = new WebSocket.Server({ port: PORT });
console.log(`WebSocket server running on ws://localhost:${PORT}`);
// Track all connected clients
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
console.log(`Client connected. Total clients: ${clients.size}`);
// Send a welcome message immediately
ws.send(JSON.stringify({
type: 'welcome',
message: 'Connected to real-time data stream',
timestamp: Date.now()
}));
ws.on('close', () => {
clients.delete(ws);
console.log(`Client disconnected. Total clients: ${clients.size}`);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
clients.delete(ws);
});
});
// Generate and broadcast data every second
setInterval(() => {
const dataPoint = {
type: 'metric',
timestamp: Date.now(),
cpu: Math.round(Math.random() * 100),
memory: Math.round(Math.random() * 60 + 20),
activeUsers: Math.round(Math.random() * 500 + 100),
requestsPerSecond: Math.round(Math.random() * 200 + 50)
};
const message = JSON.stringify(dataPoint);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}, 1000);
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down server...');
wss.close();
process.exit(0);
});
Key points about this server:
- Broadcast pattern — the
setIntervalloop pushes the same data to every connected client, which is perfect for dashboards - JSON framing — all messages are stringified JSON objects with a
typefield, allowing the client to distinguish different message kinds - Ready state check — always verify
client.readyState === WebSocket.OPENbefore sending to avoid crashes on half-closed connections - Error handling — the
errorevent prevents uncaught exceptions from killing the server
Part 2: Building the Client Dashboard (HTML + Chart.js)
Create index.html in the same project folder. We'll serve it with a simple HTTP server. This page connects to the WebSocket, maintains a rolling buffer of data, and renders three Chart.js charts in real time:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 2rem;
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
font-size: 2rem;
font-weight: 700;
}
.status-bar {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
font-size: 0.95rem;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #ef4444;
margin-right: 6px;
transition: background 0.3s;
}
.status-dot.connected { background: #22c55e; }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.chart-card {
background: #1e293b;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.chart-card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #94a3b8;
}
canvas { width: 100% !important; height: 250px !important; }
</style>
</head>
<body>
<h1>📊 Live System Monitor</h1>
<div class="status-bar">
<span><span class="status-dot" id="statusDot"></span>Connection: <strong id="connStatus">Disconnected</strong></span>
<span>Data Points: <strong id="dataCount">0</strong></span>
<span>Last Update: <strong id="lastUpdate">—</strong></span>
</div>
<div class="dashboard-grid">
<div class="chart-card">
<h2>CPU Usage (%)</h2>
<canvas id="cpuChart"></canvas>
</div>
<div class="chart-card">
<h2>Memory Usage (%)</h2>
<canvas id="memoryChart"></canvas>
</div>
<div class="chart-card">
<h2>Active Users</h2>
<canvas id="usersChart"></canvas>
</div>
<div class="chart-card">
<h2>Requests / Second</h2>
<canvas id="rpsChart"></canvas>
</div>
</div>
<script>
// ---- Configuration ----
const MAX_DATA_POINTS = 30; // Keep last 30 readings in the rolling buffer
const WS_URL = 'ws://localhost:8080';
// ---- Data Buffers ----
const timestamps = [];
const cpuData = [];
const memoryData = [];
const usersData = [];
const rpsData = [];
// ---- DOM References ----
const statusDot = document.getElementById('statusDot');
const connStatus = document.getElementById('connStatus');
const dataCountEl = document.getElementById('dataCount');
const lastUpdateEl = document.getElementById('lastUpdate');
// ---- Chart.js Helpers ----
function createChartConfig(label, dataBuffer, color, yMax) {
return {
type: 'line',
data: {
labels: timestamps,
datasets: [{
label: label,
data: dataBuffer,
borderColor: color,
backgroundColor: color + '33', // 20% opacity
borderWidth: 2,
pointRadius: 0,
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 500,
easing: 'easeInOutQuart'
},
scales: {
x: {
display: true,
ticks: { color: '#94a3b8', maxTicksLimit: 8 },
grid: { color: '#334155' }
},
y: {
min: 0,
max: yMax,
ticks: { color: '#94a3b8' },
grid: { color: '#334155' }
}
},
plugins: {
legend: { labels: { color: '#e2e8f0' } }
}
}
};
}
// ---- Initialize Charts ----
const cpuCtx = document.getElementById('cpuChart').getContext('2d');
const memoryCtx = document.getElementById('memoryChart').getContext('2d');
const usersCtx = document.getElementById('usersChart').getContext('2d');
const rpsCtx = document.getElementById('rpsChart').getContext('2d');
const cpuChart = new Chart(cpuCtx, createChartConfig('CPU %', cpuData, '#f97316', 100));
const memoryChart = new Chart(memoryCtx, createChartConfig('Memory %', memoryData, '#3b82f6', 100));
const usersChart = new Chart(usersCtx, createChartConfig('Users', usersData, '#22c55e', 700));
const rpsChart = new Chart(rpsCtx, createChartConfig('Req/s', rpsData, '#a855f7', 300));
// ---- WebSocket Connection ----
let ws = null;
let reconnectTimer = null;
let reconnectDelay = 2000; // Start with 2s, will back off
function connectWebSocket() {
if (ws && ws.readyState === WebSocket.OPEN) return;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WebSocket connected');
statusDot.classList.add('connected');
connStatus.textContent = 'Connected';
reconnectDelay = 2000; // Reset backoff on successful connection
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'welcome') {
console.log('Server says:', msg.message);
return;
}
if (msg.type === 'metric') {
// Format timestamp for X axis
const time = new Date(msg.timestamp).toLocaleTimeString();
// Add data to buffers, enforce rolling window
timestamps.push(time);
cpuData.push(msg.cpu);
memoryData.push(msg.memory);
usersData.push(msg.activeUsers);
rpsData.push(msg.requestsPerSecond);
// Trim oldest entries if we exceed MAX_DATA_POINTS
while (timestamps.length > MAX_DATA_POINTS) {
timestamps.shift();
cpuData.shift();
memoryData.shift();
usersData.shift();
rpsData.shift();
}
// Update all charts
cpuChart.update();
memoryChart.update();
usersChart.update();
rpsChart.update();
// Update status bar
dataCountEl.textContent = timestamps.length;
lastUpdateEl.textContent = time;
}
} catch (err) {
console.error('Failed to parse message:', err);
}
};
ws.onclose = (event) => {
console.log(`WebSocket closed (code: ${event.code})`);
statusDot.classList.remove('connected');
connStatus.textContent = 'Disconnected — reconnecting...';
scheduleReconnect();
};
ws.onerror = (err) => {
console.error('WebSocket error — will retry');
ws.close(); // Trigger onclose handler
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
console.log(`Reconnecting in ${reconnectDelay / 1000}s...`);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000); // Exponential backoff, max 30s
connectWebSocket();
}, reconnectDelay);
}
// ---- Start Everything ----
connectWebSocket();
</script>
</body>
</html>
Let's walk through the important parts of this client code:
Rolling Data Buffer
The arrays timestamps, cpuData, memoryData, etc., act as sliding windows. When new data arrives, we push it and then shift off the oldest entries if the array exceeds MAX_DATA_POINTS. This prevents unbounded memory growth and keeps the X axis readable.
Chart.js Animation
Notice animation: { duration: 500, easing: 'easeInOutQuart' }. This gives a smooth, professional feel when new data slides in. The pointRadius: 0 keeps the line clean — at 30+ data points, individual dots become visual noise.
Reconnection with Exponential Backoff
The scheduleReconnect function implements exponential backoff (2s → 3s → 4.5s → … → max 30s). This prevents hammering the server if it's temporarily down. The delay resets to 2s on successful connection.
Part 3: Running the Dashboard
You need to serve the HTML file over HTTP (the WebSocket server only handles WebSocket connections, not static files). Use any lightweight HTTP server. Here are three options:
Option A — Python (if installed):
python -m http.server 3000
Option B — Node.js one-liner with npx:
npx serve . -p 3000
Option C — Add static serving to server.js:
// Add to server.js (requires npm install express)
const express = require('express');
const app = express();
app.use(express.static('public')); // move index.html to public/
app.listen(3000);
Then in two terminal windows:
# Terminal 1 — start the WebSocket server
node server.js
# Terminal 2 — serve the HTML
npx serve . -p 3000
Open http://localhost:3000 in your browser. You should see four live-updating charts and a green "Connected" indicator. Open multiple tabs — each gets its own real-time stream.
Part 4: Production-Grade Enhancements
1. Authentication on WebSocket
In production, you'll want to authenticate WebSocket connections. Pass a token as a query parameter and validate it during the upgrade handshake:
// server.js — validate token during connection
const url = require('url');
wss.on('connection', (ws, req) => {
const params = new url.URL(req.url, `http://${req.headers.host}`).searchParams;
const token = params.get('token');
if (token !== 'YOUR_SECRET_TOKEN') {
ws.close(4001, 'Unauthorized');
return;
}
// ... rest of connection handler
});
On the client, include the token in the WebSocket URL:
const WS_URL = 'ws://localhost:8080?token=YOUR_SECRET_TOKEN';
2. Handling High-Frequency Data
If your data source fires 100 times per second, updating Chart.js on every message will cause jank. Implement client-side throttling:
let pendingUpdate = false;
let throttleInterval = 100; // ms between updates
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Always buffer the data...
timestamps.push(/* ... */);
cpuData.push(msg.cpu);
// ...
if (!pendingUpdate) {
pendingUpdate = true;
setTimeout(() => {
// Trim buffers...
cpuChart.update();
memoryChart.update();
usersChart.update();
rpsChart.update();
pendingUpdate = false;
}, throttleInterval);
}
};
3. Graceful Degradation
Always design for connection loss. Show the last known data with a "stale" indicator rather than blank charts. The status bar in our example already does this — but consider dimming charts or adding a "Data may be delayed" banner after 10+ seconds of silence.
4. Memory Considerations
Chart.js keeps an internal data store. If your MAX_DATA_POINTS is 30 and you receive one message per second, that's 30 points × 4 datasets = minimal memory. But if you're plotting 10,000 points across 20 charts, switch to a decimated buffer or use a specialized library like chartjs-plugin-streaming.
Best Practices Summary
- Use JSON framing — always wrap messages with a
typefield so the client can route them appropriately - Check readyState before sending — prevents crashes on half-open connections
- Implement exponential backoff — never reconnect in a tight loop; start at 2s and cap at 30s
- Keep chart data small — 30–60 data points per chart is ideal for readability and performance
- Throttle UI updates — if data arrives faster than ~100ms intervals, batch updates with a timer
- Use a separate static file server — WebSocket servers don't serve HTML; keep concerns separated
- Add heartbeat/ping — detect zombie connections by sending a ping frame every 30s and closing if no pong returns
- Secure with wss:// — in production, always use
wss://(WebSocket over TLS) behind a reverse proxy like Nginx - Log and monitor — track client counts, message rates, and error rates to spot anomalies early
Complete Server with Heartbeat & Cleanup
Here's an enhanced version of the server that includes heartbeat detection and automatic cleanup of stale connections:
// server-enhanced.js
const WebSocket = require('ws');
const PORT = 8080;
const wss = new WebSocket.Server({ port: PORT });
console.log(`WebSocket server running on ws://localhost:${PORT}`);
const clients = new Map(); // ws -> { isAlive: boolean }
// Heartbeat: server pings all clients every 30s
const HEARTBEAT_INTERVAL = 30000;
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
const info = clients.get(ws);
if (info && !info.isAlive) {
console.log('Terminating zombie connection');
ws.terminate();
clients.delete(ws);
return;
}
if (info) {
info.isAlive = false;
ws.ping(); // Send ping frame
}
});
}, HEARTBEAT_INTERVAL);
wss.on('connection', (ws) => {
clients.set(ws, { isAlive: true });
console.log(`Client connected. Total: ${clients.size}`);
ws.on('pong', () => {
const info = clients.get(ws);
if (info) info.isAlive = true; // Client responded
});
ws.send(JSON.stringify({
type: 'welcome',
message: 'Connected to real-time data stream',
timestamp: Date.now()
}));
ws.on('close', () => {
clients.delete(ws);
console.log(`Client disconnected. Total: ${clients.size}`);
});
ws.on('error', () => {
clients.delete(ws);
});
});
// Broadcast data every second
setInterval(() => {
const dataPoint = {
type: 'metric',
timestamp: Date.now(),
cpu: Math.round(Math.random() * 100),
memory: Math.round(Math.random() * 60 + 20),
activeUsers: Math.round(Math.random() * 500 + 100),
requestsPerSecond: Math.round(Math.random() * 200 + 50)
};
const payload = JSON.stringify(dataPoint);
wss.clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
}, 1000);
// Cleanup on shutdown
process.on('SIGINT', () => {
clearInterval(heartbeat);
wss.close();
process.exit(0);
});
Conclusion
Building a real-time dashboard with WebSocket and Chart.js is straightforward once you understand the core pattern: the server pushes structured JSON messages over a persistent connection, and the client buffers incoming data into a rolling window before updating Chart.js instances. This architecture replaces slow, wasteful HTTP polling with a fast, efficient push model that scales to thousands of concurrent viewers.
The code examples above give you a complete, working foundation. From here, you can swap the random data generator for real metrics — system stats via os-utils, database query results, IoT MQTT bridges, or financial tickers. Add authentication, switch to wss:// for production, and implement the throttling and heartbeat patterns covered here, and you'll have a dashboard that's not just functional but production-ready.