Skip to content

A Real-Time Dashboard with Server-Sent Events and React

A step-by-step tutorial for a live-updating dashboard with Server-Sent Events: the SSE protocol, a Node.js server and a React client with reconnection.

6 min read
Live dashboard with streaming charts and real-time data updates

Why Server-Sent Events Over WebSockets

Every real-time feature conversation defaults to WebSockets. But for dashboards, notifications, and live feeds—cases where data flows primarily from server to client—Server-Sent Events (SSE) are simpler, more reliable, and require less infrastructure.

SSE runs over standard HTTP. No protocol upgrade, no special load balancer configuration, no sticky sessions. It reconnects automatically when connections drop. It works through proxies and CDNs that block WebSocket upgrades. For unidirectional data streaming, SSE is the pragmatic choice.

This tutorial builds a real-time metrics dashboard from scratch: a Node.js server that pushes events, a React client that consumes them, and the production concerns (reconnection, error handling, backpressure) that tutorials usually skip.

The SSE Protocol in Five Minutes

SSE uses a simple text-based protocol. The server sends a response with Content-Type: text/event-stream and writes events as plain text lines. Each event has optional fields: event (type), data (payload), id (for reconnection), and retry (reconnection interval).

plaintextplaintext
event: metric
id: 1001
data: {"name":"cpu_usage","value":72.5,"timestamp":"2024-10-05T14:30:00Z"}
 
event: metric
id: 1002
data: {"name":"memory_usage","value":68.3,"timestamp":"2024-10-05T14:30:01Z"}
 
event: alert
id: 1003
data: {"severity":"warning","message":"CPU usage above 70%"}

Events are separated by double newlines. The id field enables automatic reconnection—when the client reconnects, it sends the last received ID in the Last-Event-ID header, and the server can replay missed events.

Server Implementation: Streaming Events from Node.js

The server keeps the HTTP connection open and writes events as they occur. The key implementation detail is proper cleanup when clients disconnect.

tstypescript
import { createServer, IncomingMessage, ServerResponse } from "http";
 
interface SSEClient {
  id: string;
  response: ServerResponse;
  lastEventId: number;
}
 
const clients: Map<string, SSEClient> = new Map();
let eventCounter = 0;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  const clientId = crypto.randomUUID();
 
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // Disable nginx buffering
  });
 
  // Send initial retry interval
  res.write("retry: 5000\n\n");
 
  // Handle reconnection
  const lastEventId = parseInt(
    req.headers["last-event-id"] as string || "0",
    10
  );
 
  const client: SSEClient = {
    id: clientId,
    response: res,
    lastEventId,
  };
 
  clients.set(clientId, client);
 
  // Replay missed events if reconnecting
  if (lastEventId > 0) {
    replayEvents(client, lastEventId);
  }
 
  // Cleanup on disconnect
  req.on("close", () => {
    clients.delete(clientId);
    console.log(`Client ${clientId} disconnected. Active: ${clients.size}`);
  });
 
  console.log(`Client ${clientId} connected. Active: ${clients.size}`);
}
 
function sendEvent(
  client: SSEClient,
  eventType: string,
  data: object
): boolean {
  try {
    eventCounter++;
    const payload = [
      `event: ${eventType}`,
      `id: ${eventCounter}`,
      `data: ${JSON.stringify(data)}`,
      "",
      "",
    ].join("\n");
 
    return client.response.write(payload);
  } catch {
    clients.delete(client.id);
    return false;
  }
}
 
function broadcast(eventType: string, data: object): void {
  for (const [id, client] of clients) {
    const success = sendEvent(client, eventType, data);
    if (!success) {
      clients.delete(id);
    }
  }
}

The X-Accel-Buffering: no header is critical for nginx deployments. Without it, nginx buffers the response and clients receive events in batches instead of real-time.

Event Replay for Reliable Delivery

When a client reconnects after a network interruption, it sends the last event ID it received. The server should replay any events the client missed. A bounded event buffer makes this possible without unbounded memory growth.

tstypescript
interface StoredEvent {
  id: number;
  type: string;
  data: object;
  timestamp: number;
}
 
const EVENT_BUFFER_SIZE = 1000;
const eventBuffer: StoredEvent[] = [];
 
function storeEvent(type: string, data: object): number {
  eventCounter++;
 
  const event: StoredEvent = {
    id: eventCounter,
    type,
    data,
    timestamp: Date.now(),
  };
 
  eventBuffer.push(event);
 
  // Keep buffer bounded
  if (eventBuffer.length > EVENT_BUFFER_SIZE) {
    eventBuffer.splice(0, eventBuffer.length - EVENT_BUFFER_SIZE);
  }
 
  return eventCounter;
}
 
function replayEvents(client: SSEClient, afterId: number): void {
  const missed = eventBuffer.filter((e) => e.id > afterId);
 
  for (const event of missed) {
    sendEvent(client, event.type, event.data);
  }
 
  if (missed.length > 0) {
    console.log(`Replayed ${missed.length} events for client ${client.id}`);
  }
}
 
// Modified broadcast that stores events
function broadcastAndStore(eventType: string, data: object): void {
  storeEvent(eventType, data);
  broadcast(eventType, data);
}

The buffer size of 1000 events is a tradeoff between memory usage and reconnection reliability. For a dashboard pushing one event per second, this covers about 16 minutes of disconnection. Adjust based on your expected disconnection patterns.

React Client: Consuming SSE with Hooks

The browser's built-in EventSource API handles SSE connections, automatic reconnection, and event parsing. Wrapping it in a React hook gives you clean integration with component lifecycle.

tstypescript
// ❌ Bad: Bare EventSource without cleanup or error handling
import { useEffect, useState } from "react";
 
function useBadSSE(url: string) {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = (e) => setData(JSON.parse(e.data));
    // No cleanup! Connection leaks on unmount
    // No error handling! Silent failures
  }, [url]);
 
  return data;
}
tstypescript
// ✅ Good: Full SSE hook with typed events, reconnection, and cleanup
import { useEffect, useRef, useState, useCallback } from "react";
 
interface SSEOptions {
  onOpen?: () => void;
  onError?: (error: Event) => void;
  maxRetries?: number;
}
 
interface SSEState<T> {
  data: T | null;
  isConnected: boolean;
  error: string | null;
  retryCount: number;
}
 
function useSSE<T>(
  url: string,
  eventType: string,
  options: SSEOptions = {}
): SSEState<T> {
  const [state, setState] = useState<SSEState<T>>({
    data: null,
    isConnected: false,
    error: null,
    retryCount: 0,
  });
 
  const sourceRef = useRef<EventSource | null>(null);
  const retryCountRef = useRef(0);
  const maxRetries = options.maxRetries ?? 10;
 
  const connect = useCallback(() => {
    if (sourceRef.current) {
      sourceRef.current.close();
    }
 
    const source = new EventSource(url);
    sourceRef.current = source;
 
    source.addEventListener("open", () => {
      retryCountRef.current = 0;
      setState((prev) => ({
        ...prev,
        isConnected: true,
        error: null,
        retryCount: 0,
      }));
      options.onOpen?.();
    });
 
    source.addEventListener(eventType, (event: MessageEvent) => {
      try {
        const parsed = JSON.parse(event.data) as T;
        setState((prev) => ({ ...prev, data: parsed }));
      } catch {
        console.error("Failed to parse SSE data:", event.data);
      }
    });
 
    source.addEventListener("error", (event: Event) => {
      setState((prev) => ({
        ...prev,
        isConnected: false,
        retryCount: retryCountRef.current,
      }));
 
      if (retryCountRef.current >= maxRetries) {
        source.close();
        setState((prev) => ({
          ...prev,
          error: "Max reconnection attempts reached",
        }));
      }
 
      retryCountRef.current++;
      options.onError?.(event);
    });
 
    return source;
  }, [url, eventType, maxRetries, options]);
 
  useEffect(() => {
    const source = connect();
 
    return () => {
      source.close();
      sourceRef.current = null;
    };
  }, [connect]);
 
  return state;
}

The hook tracks connection state, retry count, and errors—everything the UI needs to show connection status indicators. The EventSource API handles reconnection automatically, but the retry counter lets you implement a maximum backoff.

Dashboard Component: Assembling the Pieces

With the SSE hook in place, building the dashboard is straightforward React component composition.

tsxtsx
import { useState, useEffect } from "react";
 
interface MetricData {
  name: string;
  value: number;
  timestamp: string;
}
 
interface AlertData {
  severity: "info" | "warning" | "critical";
  message: string;
}
 
function MetricsDashboard() {
  const metrics = useSSE<MetricData>("/api/events", "metric");
  const alerts = useSSE<AlertData>("/api/events", "alert");
  const [history, setHistory] = useState<MetricData[]>([]);
 
  useEffect(() => {
    if (metrics.data) {
      setHistory((prev) => {
        const updated = [...prev, metrics.data!];
        return updated.slice(-100); // Keep last 100 data points
      });
    }
  }, [metrics.data]);
 
  return (
    <div className="grid grid-cols-1 gap-6 p-6 md:grid-cols-2">
      <ConnectionStatus isConnected={metrics.isConnected} />
 
      <MetricCard
        label="CPU Usage"
        value={metrics.data?.value ?? 0}
        unit="%"
        threshold={80}
      />
 
      <MetricHistory dataPoints={history} />
 
      {alerts.data && (
        <AlertBanner
          severity={alerts.data.severity}
          message={alerts.data.message}
        />
      )}
    </div>
  );
}
 
function ConnectionStatus({ isConnected }: { isConnected: boolean }) {
  return (
    <div className="flex items-center gap-2 text-sm">
      <span
        className={`h-2 w-2 rounded-full ${
          isConnected ? "bg-green-500" : "bg-red-500"
        }`}
      />
      {isConnected ? "Live" : "Reconnecting..."}
    </div>
  );
}
 
function MetricCard({
  label,
  value,
  unit,
  threshold,
}: {
  label: string;
  value: number;
  unit: string;
  threshold: number;
}) {
  const isWarning = value > threshold;
 
  return (
    <div
      className={`rounded-lg border p-4 ${
        isWarning ? "border-red-300 bg-red-50" : "border-gray-200"
      }`}
    >
      <p className="text-sm text-gray-500">{label}</p>
      <p className={`text-3xl font-bold ${isWarning ? "text-red-600" : ""}`}>
        {value.toFixed(1)}
        {unit}
      </p>
    </div>
  );
}

The history buffer capping at 100 data points prevents memory growth in long-running sessions. For production dashboards, consider using a circular buffer or off-loading older data points to IndexedDB.

Production Concerns: Scaling SSE

SSE connections are long-lived HTTP connections. Each connected client holds a connection open on your server. This has implications for scaling.

tstypescript
// Server-side connection limits and backpressure
const MAX_CLIENTS = 10000;
 
function setupSSEConnection(req: IncomingMessage, res: ServerResponse): void {
  if (clients.size >= MAX_CLIENTS) {
    res.writeHead(503, { "Retry-After": "30" });
    res.end("Server at capacity");
    return;
  }
 
  // Keep-alive to prevent proxy timeouts
  const keepAliveInterval = setInterval(() => {
    try {
      res.write(":keepalive\n\n");
    } catch {
      clearInterval(keepAliveInterval);
    }
  }, 15000);
 
  req.on("close", () => {
    clearInterval(keepAliveInterval);
    clients.delete(clientId);
  });
 
  // ... rest of setup
}

The keepalive comment (:keepalive) is a valid SSE comment that prevents proxies and load balancers from closing idle connections. Send one every 15-30 seconds. Without this, AWS ALB closes idle connections after 60 seconds, which triggers unnecessary reconnection cycles.

Key Takeaways

Server-Sent Events are the right tool for server-to-client streaming: dashboards, notifications, live feeds, progress indicators. They are simpler to implement than WebSockets, work through HTTP infrastructure, and handle reconnection natively.

The production details matter: buffer events for replay on reconnection, send keepalive comments to prevent proxy timeouts, cap client connections with backpressure, and clean up resources on disconnect. In the React client, track connection state and expose it to the UI so users know when data is stale.

SSE has real limitations—no client-to-server messaging (use regular POST requests), a browser limit of 6 connections per domain (HTTP/2 raises this to 100), and no binary data support. For bidirectional communication, WebSockets remain the right choice. But for the common case of pushing updates from server to client, SSE does the job with less complexity.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX