PrimeStack.
Engineering·Aug 10, 2025

Building Real-Time Dashboards with WebSocket and Redis

Learn how to architect high-frequency data pipelines for live analytics dashboards using standard web technologies.


Building real-time dashboards is one of those problems that looks simple until you push it to production. A WebSocket server and a setInterval loop can get you a prototype in a few hours. Making that prototype scale horizontally, survive server restarts, handle tens of thousands of concurrent connections, and render data at 10 updates per second without tanking browser performance is where most teams hit walls.

This article walks through the complete architecture for a production real-time dashboard: from the data transport layer to Redis as the backbone for horizontal scaling, to the React rendering patterns that handle high-frequency updates without thrashing the DOM.

Table of Contents


Choosing a Transport: WebSockets vs SSE vs Polling

The transport choice shapes everything downstream, so it's worth being deliberate.

Polling

HTTP polling sends a request on an interval, gets a response, and repeats. It's the simplest implementation and works everywhere. The problem: it's inefficient. At 1-second intervals with 1,000 concurrent users, you're generating 1,000 HTTP requests per second — even when nothing has changed. It also introduces latency equal to half the polling interval on average.

Long polling (holding the connection open until the server has data) reduces wasted requests but creates connection pressure. Each long-polled client holds a server socket open. At scale, this is similar to WebSockets in resource usage but without the framing and bidirectional benefits.

Use polling when: you need to support environments where WebSockets are blocked (some corporate proxies), you have low update frequency (every 30+ seconds), or you're prototyping and want the simplest possible implementation.

Server-Sent Events (SSE)

SSE is a browser-native standard for unidirectional server-to-client streaming over a standard HTTP connection. The server holds the response open and pushes events using a simple text protocol. The browser's EventSource API handles reconnection automatically.

SSE works over HTTP/2, which means multiple streams share a single TCP connection — unlike HTTP/1.1 which limits concurrent connections per host. It handles authentication naturally (cookies, headers). The downside: it's unidirectional. The client can't push data back on the same connection.

Use SSE when: you need server-to-client streaming without bidirectional communication, you're on HTTP/2, and you want automatic browser reconnection without client-side reconnection logic.

WebSockets

WebSockets provide a persistent, bidirectional, full-duplex channel over a single TCP connection. After the initial HTTP upgrade handshake, both sides can send messages at any time with minimal overhead (2–14 bytes of framing per message vs. 200–800 bytes for HTTP headers).

For dashboards that need client-initiated updates — subscribing to specific data streams, sending filter criteria, acknowledging messages — WebSockets are the right choice. For pure broadcast dashboards where the client never sends data, SSE is often simpler.

Use WebSockets when: you need bidirectional communication, sub-100ms latency matters, or you're streaming a high volume of small messages where HTTP header overhead would dominate.


WebSocket Server Setup

ws vs. Socket.io

ws is a minimal, spec-compliant WebSocket library for Node.js. It adds no protocol overhead beyond the WebSocket spec — what you send is exactly what arrives.

Socket.io is a higher-level library that adds: automatic fallback to polling (if WebSockets are unavailable), rooms and namespaces for pub/sub patterns, automatic reconnection with exponential backoff, and a custom framing format on top of WebSockets.

Socket.io's custom protocol means clients must use the Socket.io client library — raw WebSocket connections won't work. This is a non-trivial dependency. However, Socket.io's rooms abstraction maps naturally to the "subscribe to a data stream" pattern that dashboards need, and its reconnection handling is well-tested.

For a new project in 2025, the pragmatic choice: use Socket.io if you want the built-in room abstractions and reconnection logic. Use ws directly if you need to control the protocol precisely, support non-JavaScript clients, or want minimal dependencies.

A basic ws server:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (socket, request) => {
  console.log('Client connected');

  socket.on('message', (data) => {
    const message = JSON.parse(data.toString());
    // handle subscription requests
  });

  socket.on('close', () => {
    // cleanup subscriptions
  });
});

The Horizontal Scaling Problem

This is the critical architectural issue with WebSockets that trips up teams the first time they scale beyond a single server.

WebSocket connections are stateful and sticky. A client connected to server instance A is not connected to instance B. If a data update arrives at instance B — because your load balancer routed it there — clients on instance A never receive it.

There are two wrong solutions teams try before they find the right one:

Sticky sessions: Route each client to the same server instance for the duration of their session. This works until that server instance restarts or is terminated. All those clients reconnect, and the reconnections get distributed across instances — breaking the stickiness.

Direct inter-process communication: Have server instances communicate directly with each other. This creates a mesh of connections that grows as O(n²) with your instance count and requires each server to maintain awareness of every other server. It doesn't survive auto-scaling.

The correct solution: treat your WebSocket servers as stateless message delivery agents. The source of truth for which messages to deliver lives outside the WebSocket server, in a message broker — specifically, Redis Pub/Sub.


Redis Pub/Sub as the Message Broker

Redis Pub/Sub provides a publish/subscribe mechanism where publishers push messages to channels and all subscribers to that channel receive them — without publishers knowing who the subscribers are.

The architecture for horizontally scalable WebSockets:

  1. Each WebSocket server instance subscribes to the relevant Redis channels on startup.
  2. When a data update occurs (from any source — an API, a background job, another service), it publishes to the appropriate Redis channel.
  3. Every WebSocket server instance receives the message and broadcasts it to all connected clients subscribed to that topic.
import { createClient } from 'redis';
import { WebSocketServer } from 'ws';

const subscriber = createClient();
await subscriber.connect();

const wss = new WebSocketServer({ port: 8080 });

// Map of topic → Set of WebSocket clients
const subscriptions = new Map();

wss.on('connection', (socket) => {
  socket.on('message', (data) => {
    const { type, topic } = JSON.parse(data.toString());
    if (type === 'subscribe') {
      if (!subscriptions.has(topic)) {
        subscriptions.set(topic, new Set());
        subscriber.subscribe(topic, (message) => {
          // broadcast to all local clients subscribed to this topic
          subscriptions.get(topic)?.forEach((client) => {
            if (client.readyState === WebSocket.OPEN) {
              client.send(message);
            }
          });
        });
      }
      subscriptions.get(topic).add(socket);
    }
  });

  socket.on('close', () => {
    // remove socket from all subscriptions
    subscriptions.forEach((clients) => clients.delete(socket));
  });
});

Now you can run this WebSocket server on any number of instances behind a load balancer. Each instance independently receives Redis messages and delivers them to its connected clients.


Redis Streams for Durable Event Pipelines

Redis Pub/Sub is fire-and-forget: if a subscriber is down when a message is published, it misses that message. For a live dashboard where a short service restart causes no visible gap, that's acceptable. For an audit trail or a system where clients need to catch up after reconnecting, it's not.

Redis Streams (XADD, XREAD, XREADGROUP) provide a persistent, ordered, consumer-group-aware message queue. Messages are retained on the stream until explicitly deleted. Consumers can read from a specific offset, allowing a reconnecting client to request all messages since its last acknowledged position.

// Producer: append to stream
await redis.xAdd('metrics:stream', '*', {
  metric: 'requests_per_second',
  value: '1423',
  timestamp: Date.now().toString(),
});

// Consumer: read new messages
const messages = await redis.xRead(
  [{ key: 'metrics:stream', id: lastId }],
  { COUNT: 100, BLOCK: 5000 }
);

Use Redis Streams when you need: message persistence across restarts, the ability for clients to replay missed events, or consumer group semantics where messages should be processed exactly once by one consumer in a group.


The Complete Data Flow Architecture

A production real-time dashboard data flow:

Data Source
    │ (write)

Redis Stream (durable buffer)
    │ (consumer group reads)

Stream Processor Service
    │ (XADD or PUBLISH)

Redis Pub/Sub Channel
    │ (subscribed)

WebSocket Server Instances (N)
    │ (broadcast to subscribed clients)

Browser Clients

The Stream Processor is a separate service that reads from the Redis Stream, applies any transformations or aggregations, and publishes to the appropriate Pub/Sub channels. This separation means: the data source doesn't need to know about WebSocket topology, processing logic is decoupled from delivery, and you can scale each layer independently.

Data sources write to the stream and go on with their work. The stream acts as a backpressure buffer — if the processor falls behind, messages accumulate in the stream rather than being dropped. The WebSocket servers are thin delivery agents with no business logic.


Client-Side Rendering for High-Frequency Updates

Receiving 10 updates per second per metric across 50 metrics is 500 state updates per second. Naively calling setState on every message will destroy React rendering performance and peg the CPU.

Throttling Updates

The simplest fix: don't apply every update immediately. Batch incoming messages and apply them at a capped rate.

const messageBuffer = useRef([]);
const lastRender = useRef(0);

useEffect(() => {
  const flush = () => {
    const now = Date.now();
    if (messageBuffer.current.length > 0 && now - lastRender.current > 100) {
      // Apply all buffered updates at once
      setMetrics((prev) => applyUpdates(prev, messageBuffer.current));
      messageBuffer.current = [];
      lastRender.current = now;
    }
    rafId = requestAnimationFrame(flush);
  };

  let rafId = requestAnimationFrame(flush);
  return () => cancelAnimationFrame(rafId);
}, []);

requestAnimationFrame for Smooth Rendering

Tie your render flush to requestAnimationFrame. The browser calls this at the display refresh rate (typically 60fps), ensuring your updates are synchronized with the screen's draw cycle. Updates faster than 60fps are invisible to the user and just waste CPU.

Virtual Lists for Large Datasets

If your dashboard includes a live event log or any list that grows continuously, a full DOM list will eventually cause scroll performance degradation. Use a virtual list library (TanStack Virtual, react-window) to render only the rows currently in the viewport. A virtual list of 100,000 rows renders the same as a list of 20 rows from the browser's perspective.


React Patterns for Real-Time Data

useEffect with Cleanup

WebSocket connections in React components require careful cleanup to avoid memory leaks and zombie connections:

useEffect(() => {
  const socket = new WebSocket('wss://api.example.com/ws');

  socket.onmessage = (event) => {
    const data = JSON.parse(event.data);
    dispatch({ type: 'UPDATE', payload: data });
  };

  return () => {
    socket.close();
  };
}, []);

In React 18 with Strict Mode, effects run twice in development. If your WebSocket connection setup has side effects that shouldn't run twice, the cleanup function must fully undo the setup. This is a common source of double-connection bugs in development.

Custom Hooks

Encapsulate WebSocket logic in a custom hook to keep components clean and make the connection logic reusable:

function useMetricsStream(topic) {
  const [data, setData] = useState(null);
  const socketRef = useRef(null);

  useEffect(() => {
    const socket = new WebSocket('wss://api.example.com/ws');
    socketRef.current = socket;

    socket.onopen = () => {
      socket.send(JSON.stringify({ type: 'subscribe', topic }));
    };

    socket.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    return () => socket.close();
  }, [topic]);

  return data;
}

Zustand for Real-Time State

For dashboards with multiple components subscribing to the same data streams, lifting WebSocket state to a Zustand store prevents duplicate connections and ensures all components receive the same update atomically:

const useMetricsStore = create((set) => ({
  metrics: {},
  updateMetric: (key, value) =>
    set((state) => ({ metrics: { ...state.metrics, [key]: value } })),
}));

A single WebSocket connection at the app level feeds the store. Components subscribe to slices of the store. This is cleaner than prop-drilling update handlers through a component tree.


Connection Management

Reconnection Logic

Network interruptions are inevitable. Implement exponential backoff with jitter for reconnection attempts:

function connect(attempt = 0) {
  const socket = new WebSocket('wss://api.example.com/ws');

  socket.onclose = (event) => {
    if (!event.wasClean) {
      const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
      setTimeout(() => connect(attempt + 1), delay);
    }
  };

  socket.onopen = () => {
    // reset attempt counter on successful connection
    resubscribeToTopics(socket);
  };
}

The Math.random() * 1000 jitter prevents a thundering herd — all clients reconnecting simultaneously after a server restart — which would immediately overload the newly restarted server.

Heartbeats

Some load balancers and firewalls close idle WebSocket connections after a timeout (often 60–120 seconds). Implement a ping/pong heartbeat to keep connections alive:

// Server side
const interval = setInterval(() => {
  wss.clients.forEach((socket) => {
    if (!socket.isAlive) return socket.terminate();
    socket.isAlive = false;
    socket.ping();
  });
}, 30000);

wss.on('connection', (socket) => {
  socket.isAlive = true;
  socket.on('pong', () => { socket.isAlive = true; });
});

Backpressure

If a slow client can't receive messages as fast as they arrive, the server's send buffer grows unboundedly. Check socket.bufferedAmount before sending and drop or downsample messages if the buffer is backed up. For a dashboard, showing a slightly stale value is better than crashing the connection.


Load Testing Real-Time Systems

Standard HTTP load testing tools don't simulate WebSocket clients. Use k6 with the WebSocket API or Artillery with the Socket.io plugin.

A k6 script that simulates concurrent WebSocket clients:

import ws from 'k6/ws';
import { check } from 'k6';

export const options = {
  vus: 1000,
  duration: '60s',
};

export default function () {
  const url = 'wss://api.example.com/ws';
  const res = ws.connect(url, {}, (socket) => {
    socket.on('open', () => {
      socket.send(JSON.stringify({ type: 'subscribe', topic: 'metrics' }));
    });

    socket.on('message', (data) => {
      check(data, { 'message received': (d) => d.length > 0 });
    });

    socket.setTimeout(() => socket.close(), 55000);
  });

  check(res, { 'status is 101': (r) => r && r.status === 101 });
}

What to measure: connection establishment time under load, message delivery latency (p50/p95/p99) as connection count scales, Redis Pub/Sub throughput and latency, memory per WebSocket connection on the server (typically 50–100KB in Node.js), and behavior during instance restart (reconnection storm, Redis state).


Production Reference Architecture

The complete production architecture for a high-scale real-time dashboard:

Ingestion Layer: Data sources write to Redis Streams via a thin ingestion API. The stream acts as a durable buffer, decoupling data producers from the processing pipeline.

Processing Layer: A horizontally scalable stream processor service reads from Redis Streams using consumer groups. It applies aggregations, transformations, and routing logic, then publishes processed events to Redis Pub/Sub channels segmented by data type and client subscription criteria.

Delivery Layer: Multiple WebSocket server instances (Node.js + ws or Socket.io) subscribe to Redis Pub/Sub. Each instance maintains a local subscription registry mapping topic names to connected socket sets. On receiving a Redis message, it broadcasts to the local subscriber set. The instances are stateless beyond their in-memory subscription registry, which is rebuilt from client subscription messages on reconnect.

Load Balancer: A Layer 7 load balancer (nginx, AWS ALB) with WebSocket support routes connections to WebSocket server instances. Sticky sessions are NOT required — any instance can serve any client because state lives in Redis, not in the server.

Client Layer: Browser clients use a custom hook wrapping a WebSocket connection with reconnection logic and topic subscription management. State is managed in Zustand. Updates are throttled to requestAnimationFrame for smooth rendering.

Monitoring: Track connection counts per instance, Redis Pub/Sub message rates and latency, WebSocket send queue depth per connection, and client-side rendering frame rates with performance.now() measurements.

This architecture handles tens of thousands of concurrent connections per WebSocket server instance at low latency, scales horizontally without limit, and survives individual server failures transparently from the client's perspective. The Redis layer is the single point requiring high availability configuration — use Redis Sentinel or Redis Cluster depending on your data volume and redundancy requirements.