Build a Real-Time Dashboard with Server-Sent Events and React
WebSockets are overkill for dashboards. SSE gives you real-time push with zero library dependencies, automatic reconnection, and works through every proxy.
WebSockets are the default answer for "real-time." But for dashboards — where the server pushes data and the client just renders — they're overkill.
Server-Sent Events (SSE) are simpler, lighter, and built into every browser. No library needed. Automatic reconnection included. Let's build a full real-time metrics dashboard.
Why SSE Over WebSockets for Dashboards
| Feature | WebSockets | SSE | |---------|-----------|-----| | Direction | Bidirectional | Server → Client | | Protocol | Custom | HTTP | | Reconnection | Manual | Automatic | | Browser support | All modern | All modern | | Proxy/CDN friendly | Tricky | Works everywhere | | Dependencies | ws library | Zero |
Dashboards are read-only streams. You don't need bidirectional communication. SSE gives you exactly what you need with less complexity.
Part 1: The Server (Node.js)
// server.ts
import http from "http";
type Metric = {
cpu: number;
memory: number;
requests: number;
errors: number;
latency: number;
timestamp: number;
};
function randomMetric(): Metric {
return {
cpu: Math.round(20 + Math.random() * 60),
memory: Math.round(40 + Math.random() * 40),
requests: Math.round(100 + Math.random() * 900),
errors: Math.round(Math.random() * 15),
latency: Math.round(10 + Math.random() * 200),
timestamp: Date.now(),
};
}
const clients = new Set<http.ServerResponse>();
function broadcast(data: object) {
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
client.write(payload);
}
}
const server = http.createServer((req, res) => {
if (req.url === "/events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
});
// Send initial data immediately
res.write(`data: ${JSON.stringify(randomMetric())}\n\n`);
clients.add(res);
req.on("close", () => {
clients.delete(res);
});
return;
}
// Serve static for demo
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<h1>SSE Dashboard Server</h1><p>Connect to /events</p>");
});
// Push metrics every second
setInterval(() => {
broadcast(randomMetric());
}, 1000);
server.listen(8080, () => {
console.log("Dashboard server running on :8080");
});
That's the entire backend — 50 lines. Every connected client gets a JSON metric every second via SSE.
Part 2: React Dashboard Client
// Dashboard.tsx
import { useEffect, useRef, useState } from "react";
type Metric = {
cpu: number;
memory: number;
requests: number;
errors: number;
latency: number;
timestamp: number;
};
function useSSE<T>(url: string): T | null {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
const source = new EventSource(url);
source.onmessage = (event) => {
setData(JSON.parse(event.data));
};
source.onerror = () => {
// EventSource auto-reconnects — just log it
console.warn("SSE connection lost, reconnecting...");
};
return () => source.close();
}, [url]);
return data;
}
function MetricCard({
label,
value,
unit,
color,
max = 100,
}: {
label: string;
value: number;
unit: string;
color: string;
max?: number;
}) {
const pct = Math.min(100, (value / max) * 100);
return (
<div style={{
background: "#1a1a2e",
borderRadius: 12,
padding: 24,
minWidth: 200,
}}>
<p style={{ color: "#888", fontSize: 12, textTransform: "uppercase" }}>
{label}
</p>
<p style={{ color, fontSize: 36, fontWeight: 700, margin: "8px 0" }}>
{value.toLocaleString()}
<span style={{ fontSize: 14, color: "#666" }}> {unit}</span>
</p>
<div style={{
height: 4,
background: "#2a2a3e",
borderRadius: 2,
overflow: "hidden",
}}>
<div
style={{
height: "100%",
width: `${pct}%`,
background: color,
borderRadius: 2,
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
}
function SparkLine({ data, color }: { data: number[]; color: string }) {
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const w = 200;
const h = 40;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * h;
return `${x},${y}`;
})
.join(" ");
return (
<svg width={w} height={h} style={{ display: "block" }}>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function Dashboard() {
const metric = useSSE<Metric>("http://localhost:8080/events");
const historyRef = useRef<Metric[]>([]);
if (metric) {
historyRef.current = [...historyRef.current.slice(-59), metric];
}
const history = historyRef.current;
if (!metric) {
return (
<div style={{ color: "#888", textAlign: "center", padding: 100 }}>
Connecting to dashboard...
</div>
);
}
return (
<div style={{ background: "#0f0f1a", minHeight: "100vh", padding: 40 }}>
<h1 style={{ color: "#fff", fontSize: 24, marginBottom: 8 }}>
System Dashboard
</h1>
<p style={{ color: "#666", fontSize: 14, marginBottom: 32 }}>
Live metrics · updates every second
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16, marginBottom: 32 }}>
<MetricCard label="CPU" value={metric.cpu} unit="%" color="#3B82F6" />
<MetricCard label="Memory" value={metric.memory} unit="%" color="#10B981" />
<MetricCard label="Requests" value={metric.requests} unit="/min" color="#F59E0B" max={1000} />
<MetricCard label="Errors" value={metric.errors} unit="" color="#EF4444" max={20} />
<MetricCard label="Latency" value={metric.latency} unit="ms" color="#8B5CF6" max={300} />
</div>
{history.length > 1 && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 16 }}>
<div style={{ background: "#1a1a2e", borderRadius: 12, padding: 16 }}>
<p style={{ color: "#888", fontSize: 11, marginBottom: 8 }}>CPU (60s)</p>
<SparkLine data={history.map((m) => m.cpu)} color="#3B82F6" />
</div>
<div style={{ background: "#1a1a2e", borderRadius: 12, padding: 16 }}>
<p style={{ color: "#888", fontSize: 11, marginBottom: 8 }}>Latency (60s)</p>
<SparkLine data={history.map((m) => m.latency)} color="#8B5CF6" />
</div>
<div style={{ background: "#1a1a2e", borderRadius: 12, padding: 16 }}>
<p style={{ color: "#888", fontSize: 11, marginBottom: 8 }}>Errors (60s)</p>
<SparkLine data={history.map((m) => m.errors)} color="#EF4444" />
</div>
</div>
)}
</div>
);
}
The useSSE Hook
The entire real-time connection is 15 lines:
function useSSE<T>(url: string): T | null {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
const source = new EventSource(url);
source.onmessage = (event) => {
setData(JSON.parse(event.data));
};
source.onerror = () => {
console.warn("SSE connection lost, reconnecting...");
};
return () => source.close();
}, [url]);
return data;
}
EventSource is a browser built-in. It:
- Opens an HTTP connection to the server
- Parses incoming
data:events as they arrive - Automatically reconnects if the connection drops
- Can carry a
Last-Event-IDheader for resuming where you left off
No npm install. No WebSocket library. No reconnection logic.
Exercises
Exercise 1: Named Event Types
Send different event types from the server:
// Server
res.write(`event: metric\ndata: ${JSON.stringify(metric)}\n\n`);
res.write(`event: alert\ndata: ${JSON.stringify({ message: "CPU spike!" })}\n\n`);
// Client
source.addEventListener("metric", (e) => {
setData(JSON.parse(e.data));
});
source.addEventListener("alert", (e) => {
showNotification(JSON.parse(e.data).message);
});
Exercise 2: Connection Status Indicator
Show connection state in the UI:
function useSSE<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<"connecting" | "open" | "error">("connecting");
useEffect(() => {
const source = new EventSource(url);
source.onopen = () => setStatus("open");
source.onmessage = (e) => setData(JSON.parse(e.data));
source.onerror = () => setStatus("error");
return () => source.close();
}, [url]);
return { data, status };
}
Exercise 3: Historical Data with Last-Event-ID
Resume from where you left off after a reconnect:
// Server — include ID with each event
let eventId = 0;
res.write(`id: ${eventId++}\ndata: ${JSON.stringify(metric)}\n\n`);
// On reconnect, browser sends Last-Event-ID header automatically
const lastId = req.headers["last-event-id"];
if (lastId) {
// Send missed events since lastId
}
When to Use SSE vs WebSockets
Use SSE when:
- Data flows in one direction (server → client)
- You need automatic reconnection
- You're behind corporate proxies or CDNs
- You want zero dependencies
Use WebSockets when:
- You need bidirectional communication (chat, games)
- You need binary data transfer
- You need custom protocols
For 90% of dashboards, monitoring pages, and live feeds — SSE is the right choice.
What dashboard are you building? Tell us at pmml.info@gmail.com.
PMML Engineering builds real-time systems, web platforms, and AI products for teams that ship. Based in Accra, Ghana.