Building a Real-Time Dashboard with React, Node.js, and Socket.io

Learn how to build a production-ready real-time dashboard using React, Node.js, and Socket.io. This tutorial covers WebSocket vs polling, live chart updates with Recharts, WebSocket authentication, and scaling with the Redis adapter.

Building a Real-Time Dashboard with React, Node.js, and Socket.io

By Elhassane Mehdioui, Full Stack Web Developer — June 15, 2026


Real-time data is no longer a luxury reserved for trading platforms and enterprise monitoring tools. Whether you are tracking user activity, server metrics, order volumes, or live analytics, users today expect their dashboards to update without a page refresh. In this guide, you will build a production-ready real-time dashboard using React, Node.js, and Socket.io, with live chart updates powered by Recharts, proper WebSocket authentication, and horizontal scaling via the Redis adapter.


WebSocket vs Polling: Why It Matters

Before writing a single line of code, it is worth understanding why WebSockets exist in the first place.

HTTP Polling is the naive approach: the client sends a request to the server on a fixed interval (say, every two seconds) and asks "anything new?" This works, but it wastes bandwidth, hammers your server with unnecessary requests, and introduces latency equal to your polling interval.

Long Polling improves on this by keeping the HTTP connection open until the server has something to send, then immediately re-opening the connection. Latency drops, but you still pay the overhead of HTTP handshakes.

WebSockets solve the problem properly. A single TCP connection is established via an HTTP upgrade handshake, and both the client and server can push messages to each other at any time with near-zero overhead. For a live data dashboard, this is exactly what you need.

Socket.io sits on top of the WebSocket protocol and adds:

  • Automatic fallback to long polling when WebSockets are unavailable
  • Rooms and namespaces for logical message grouping
  • Built-in reconnection with exponential back-off
  • Event-based API that feels natural in a Node.js/React context

Project Structure

realtime-dashboard/
├── server/
│   ├── index.js
│   ├── auth.js
│   └── redisAdapter.js
└── client/
    ├── src/
    │   ├── App.jsx
    │   ├── hooks/
    │   │   └── useSocket.js
    │   └── components/
    │       └── LiveChart.jsx
    └── package.json

Setting Up the Node.js Server with Socket.io

Start by installing the server dependencies.

mkdir server && cd server
npm init -y
npm install express socket.io jsonwebtoken redis @socket.io/redis-adapter

Basic Server with Socket.io

// server/index.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { authenticateSocket } = require('./auth');
const { applyRedisAdapter } = require('./redisAdapter');

const app = express();
const httpServer = http.createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:5173', // Vite dev server
    methods: ['GET', 'POST'],
    credentials: true,
  },
});

// Apply Redis adapter for horizontal scaling
applyRedisAdapter(io);

// WebSocket authentication middleware
io.use(authenticateSocket);

// Connection handler
io.on('connection', (socket) => {
  console.log(`Client connected: ${socket.id} (user: ${socket.data.user.id})`);

  // Join a room scoped to the authenticated user
  socket.join(`user:${socket.data.user.id}`);

  socket.on('disconnect', (reason) => {
    console.log(`Client disconnected: ${socket.id} — ${reason}`);
  });
});

// Simulate live metric updates every second
setInterval(() => {
  const payload = {
    timestamp: Date.now(),
    cpu: Math.random() * 100,
    memory: 40 + Math.random() * 40,
    requests: Math.floor(Math.random() * 500),
  };

  io.emit('metrics:update', payload);
}, 1000);

httpServer.listen(4000, () => {
  console.log('Server listening on http://localhost:4000');
});

WebSocket Authentication

Securing your WebSocket connection is non-negotiable in production. The cleanest approach is to send a JWT in the Socket.io handshake auth object and validate it on the server before the connection is established.

// server/auth.js
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

function authenticateSocket(socket, next) {
  const token = socket.handshake.auth?.token;

  if (!token) {
    return next(new Error('Authentication error: no token provided'));
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    socket.data.user = decoded; // attach user to socket for later use
    next();
  } catch (err) {
    next(new Error('Authentication error: invalid token'));
  }
}

module.exports = { authenticateSocket };

On the client, you pass the token when creating the Socket.io instance:

const socket = io('http://localhost:4000', {
  auth: { token: localStorage.getItem('access_token') },
});

If the token is invalid or missing, the server calls next(new Error(...)) which triggers a connect_error event on the client — easy to handle in your React error boundary or connection hook.


Scaling with the Redis Adapter

A single Node.js process handles WebSocket connections in memory. The moment you run more than one server instance (behind a load balancer, in a Kubernetes cluster, or on a multi-core machine with Node.js cluster), io.emit() only reaches clients connected to that process.

The Redis adapter solves this by using Redis Pub/Sub to broadcast events across all server instances.

// server/redisAdapter.js
const { createClient } = require('redis');
const { createAdapter } = require('@socket.io/redis-adapter');

async function applyRedisAdapter(io) {
  const pubClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
  const subClient = pubClient.duplicate();

  await Promise.all([pubClient.connect(), subClient.connect()]);

  io.adapter(createAdapter(pubClient, subClient));

  console.log('Redis adapter applied — ready for horizontal scaling');
}

module.exports = { applyRedisAdapter };

Now every io.emit(), io.to(room).emit(), or socket.broadcast.emit() is automatically fanned out to all server instances via Redis. Your load balancer should use sticky sessions (or IP hash) to keep each client consistently connected to the same server instance for the WebSocket upgrade — though Socket.io's long-polling fallback works across instances without this.


React Client: Custom Hook for Live Data

On the client side, create a React project and install dependencies.

npm create vite@latest client -- --template react
cd client
npm install socket.io-client recharts

useSocket Hook

Encapsulating Socket.io logic in a custom hook keeps your components clean and makes it trivial to mock in tests.

// client/src/hooks/useSocket.js
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';

export function useSocket(url, events) {
  const socketRef = useRef(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const token = localStorage.getItem('access_token');

    socketRef.current = io(url, {
      auth: { token },
      transports: ['websocket'], // skip polling entirely in modern browsers
    });

    const socket = socketRef.current;

    socket.on('connect', () => setConnected(true));
    socket.on('disconnect', () => setConnected(false));
    socket.on('connect_error', (err) => {
      console.error('Socket connection error:', err.message);
    });

    // Register all provided event handlers
    Object.entries(events).forEach(([event, handler]) => {
      socket.on(event, handler);
    });

    return () => {
      Object.entries(events).forEach(([event, handler]) => {
        socket.off(event, handler);
      });
      socket.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url]);

  return { socket: socketRef.current, connected };
}

The events map lets you register any number of server events declaratively. The cleanup function in the useEffect return removes listeners and disconnects the socket when the component unmounts, preventing memory leaks.


Live Chart Updates with Recharts

Recharts re-renders whenever its data prop changes, making it a natural fit for streaming data. Keep a rolling buffer of the last N data points in state and append to it on every metrics:update event.

// client/src/components/LiveChart.jsx
import { useState, useCallback } from 'react';
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  ResponsiveContainer,
} from 'recharts';
import { useSocket } from '../hooks/useSocket';

const MAX_POINTS = 30;

function formatTime(ts) {
  return new Date(ts).toLocaleTimeString();
}

export default function LiveChart() {
  const [metrics, setMetrics] = useState([]);

  const handleMetricsUpdate = useCallback((data) => {
    setMetrics((prev) => {
      const next = [...prev, { ...data, time: formatTime(data.timestamp) }];
      return next.length > MAX_POINTS ? next.slice(next.length - MAX_POINTS) : next;
    });
  }, []);

  const { connected } = useSocket('http://localhost:4000', {
    'metrics:update': handleMetricsUpdate,
  });

  return (
    <div style={{ padding: '2rem' }}>
      <h2>
        Server Metrics{' '}
        <span style={{ color: connected ? '#22c55e' : '#ef4444', fontSize: '0.8rem' }}>
          {connected ? 'LIVE' : 'DISCONNECTED'}
        </span>
      </h2>

      <ResponsiveContainer width="100%" height={320}>
        <LineChart data={metrics}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="time" tick={{ fontSize: 11 }} />
          <YAxis domain={[0, 100]} />
          <Tooltip />
          <Legend />
          <Line
            type="monotone"
            dataKey="cpu"
            stroke="#6366f1"
            dot={false}
            isAnimationActive={false}
            name="CPU %"
          />
          <Line
            type="monotone"
            dataKey="memory"
            stroke="#22c55e"
            dot={false}
            isAnimationActive={false}
            name="Memory %"
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

Setting isAnimationActive={false} on each Line is critical. Recharts animates every re-render by default, and with updates arriving every second that creates a constant stuttering effect. Disabling animation gives you a smooth, real-time scrolling chart instead.


Wiring It All Together

// client/src/App.jsx
import LiveChart from './components/LiveChart';

export default function App() {
  return (
    <main>
      <h1>Real-Time Dashboard</h1>
      <LiveChart />
    </main>
  );
}

Start both the server and the client:

# Terminal 1
cd server && node index.js

# Terminal 2
cd client && npm run dev

Open http://localhost:5173 and watch the chart update in real time.


Production Checklist

Before shipping your real-time dashboard to production, work through this checklist:

  • TLS/WSS: Terminate SSL at your load balancer or reverse proxy (nginx/Caddy). Socket.io will automatically use wss:// when the page is served over HTTPS.
  • Sticky sessions: Configure your load balancer to route the same client IP to the same backend instance during the WebSocket upgrade handshake.
  • Rate limiting: Throttle how frequently clients can emit events using socket.io-rate-limiter or a custom middleware to prevent abuse.
  • Token refresh: JWTs expire. Handle connect_error on the client to silently refresh the token and reconnect before surfacing an error to the user.
  • Graceful shutdown: Listen for SIGTERM and call io.close() followed by httpServer.close() to drain existing WebSocket connections before your process exits.
  • Monitoring: Instrument your Socket.io server with Prometheus metrics (active connections, events/sec, error rate) using socket.io-prometheus-metrics.

Conclusion

You now have a fully functional real-time dashboard stack: a Node.js server emitting live metrics over Socket.io, a secure JWT-based WebSocket authentication layer, a Redis adapter ready for horizontal scaling, and a React front-end with a clean custom hook driving live Recharts updates.

The architecture described here is intentionally simple to teach the fundamentals, but every piece — the auth middleware, the Redis adapter, the rolling data buffer, the useSocket hook — is production-grade and used in real applications. From here, you can extend it with rooms for multi-tenant isolation, namespaces for different metric domains, or replace the simulated data source with an actual stream from Kafka, TimescaleDB, or your application's event bus.

Real-time is not hard once you understand the primitives. Now go build something that moves.


Elhassane Mehdioui is a Full Stack Web Developer specialising in React, Node.js, and scalable web architecture.

Need a React or Next.js interface?

Modern, responsive front-ends built to perform.

I build fast, accessible UIs with React, Next.js, TypeScript and Tailwind CSS.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://