← Back to DevBytes

How to Build a Real-Time Dashboard with WebSocket and Chart.js

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles