Stop Defaulting to WebSockets: Why I Switched to SSE for Live Dashboards
While building a dashboard with a Node.js backend, I needed to push live updates to the browser without requiring users to refresh the page. My initial assumption was that WebSockets were the obvious choice. In web development, WebSockets have practically become the default reflex whenever the term "real-time" comes up.
Once I started mapping out the actual requirements, that assumption fell apart. The dashboard was passive: the browser only needed to consume live metric updates from the server. It never needed to push messages back over that same stream. WebSockets introduced a stateful, bidirectional protocol when the underlying problem was strictly unidirectional.
Stepping back to compare WebSockets with Server-Sent Events (SSE) changed how I view real-time architecture: choosing a protocol depends on how data flows through your system, not merely on how often that data changes.
The Default Reflex: When Two-Way Communication Is Overkill
WebSockets establish a persistent, bidirectional TCP connection. Following an HTTP handshake, the connection upgrades to ws:// or wss://, allowing both the client and server to exchange data frames freely at any time.
That full-duplex setup makes sense for interactive applications like chat tools, shared whiteboards, or multiplayer games. But for a monitoring dashboard, the client is almost entirely a consumer. When a user changes a filter, shifts a date range, or adjusts a display setting, that interaction is standard request/response work that belongs in ordinary REST or GraphQL POST/PUT endpoints.
Using WebSockets solely for server-to-client streaming introduces maintenance trade-offs without leveraging the protocol's core feature:
- You have to manage connection lifecycle events for both directions manually.
- You leave the standard HTTP request/response paradigm, bypassing native caching, standard headers, and conventional status codes.
- You often need custom ping/pong heartbeats to detect dead TCP connections.
If the browser never sends messages back over the socket, paying the operational and architectural cost of a bidirectional channel is unnecessary.
Protocol Mechanics: Unidirectional EventSource vs. WebSockets
Server-Sent Events (SSE) run on top of regular HTTP. The browser opens a standard HTTP request using the native EventSource API, specifying the text/event-stream MIME type. The server holds this connection open indefinitely and streams plain-text events using simple field prefixes (data:, event:, id:, and retry:).
event: metric-update
id: 1042
data: {"cpu": 42.1, "memory": 68.4}
Because SSE is standard HTTP, it fits cleanly into existing infrastructure:
- Standard Infrastructure: It works out of the box with standard reverse proxies, load balancers, and API gateways without requiring protocol upgrade configurations (though proxy response buffering must be disabled, which is worth verifying in your deployment setup).
- HTTP/2 Multiplexing: Under HTTP/1.1, browsers limit concurrent connections to six per domain. Over HTTP/2, an SSE stream shares a single TCP connection with normal API traffic, preventing connection starvation.
-
Native Browser Support: The browser's native
EventSourceinterface manages disconnections, backoff timers, and reconnection attempts without external libraries.
Handling Disconnections, Reconnects, and Duplicate Updates
Handling edge cases during network drops is where SSE shows its practical advantages over bare WebSockets.
Automatic Reconnection
With raw WebSockets, if a connection drops due to a network hiccup or a server restart, you must write custom logic to detect the disconnect, instantiate a new connection, and implement exponential backoff so your backend doesn't get flooded.
With SSE, reconnection is part of the browser specification. If the stream closes unexpectedly, EventSource automatically attempts to reconnect. You can even tune the reconnect delay directly from the server by emitting a retry: <milliseconds> line in the stream.
Duplicate Message Prevention
A major challenge in any real-time stream is ensuring clients do not drop events or process duplicates when reconnecting.
SSE provides a built-in mechanism for this through event IDs:
- The server tags each outgoing event with an
id:value. - When the connection drops, the browser retains the last ID it processed.
- Upon reconnecting, the browser automatically sends that ID in the
Last-Event-IDHTTP header. - The server inspects
req.headers['last-event-id'], retrieves any events that occurred after that ID, and streams the missed backlog before continuing with live data.
To achieve this over WebSockets, you have to design and maintain your own message sequence tracking, acknowledgment handshakes, and replay buffers on both client and server.
Implementation Comparison in Node.js
Here is how minimal implementations compare when pushing periodic dashboard metrics to a browser.
Server-Sent Events (Native Node.js HTTP)
SSE requires no third-party socket packages on either the server or client.
// server.js
import http from 'node:http';
const server = http.createServer((req, res) => {
if (req.url === '/api/metrics-stream') {
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// Read the client's last seen event ID to prevent duplicate/missed data
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
// In production: fetch and write missing items starting after lastEventId
}
// Send an update every 3 seconds
const interval = setInterval(() => {
const id = Date.now();
const data = JSON.stringify({ memoryUsage: process.memoryUsage().heapUsed });
res.write(`id: ${id}\n`);
res.write(`event: dashboard-metrics\n`);
res.write(`data: ${data}\n\n`);
}, 3000);
// Clean up when client disconnects
req.on('close', () => {
clearInterval(interval);
res.end();
});
} else {
res.writeHead(404);
res.end();
}
});
server.listen(3000);
On the frontend, the native browser API handles everything:
// client.js
const stream = new EventSource('/api/metrics-stream');
stream.addEventListener('dashboard-metrics', (event) => {
const metrics = JSON.parse(event.data);
console.log('Received live metrics:', metrics);
});
stream.onerror = (err) => {
// EventSource will automatically retry connecting;
// log errors here if needed for monitoring.
console.error('SSE connection error:', err);
};
The WebSocket Equivalent
To achieve the same passive dashboard feed with WebSockets, you typically bring in a library like ws and manage the handshake and reconnection yourself:
// server-ws.js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3001 });
wss.on('connection', (ws) => {
const interval = setInterval(() => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: 'dashboard-metrics',
data: { memoryUsage: process.memoryUsage().heapUsed },
timestamp: Date.now()
}));
}
}, 3000);
ws.on('close', () => {
clearInterval(interval);
});
});
And on the client:
// client-ws.js
let socket;
function connect() {
socket = new WebSocket('ws://localhost:3001');
socket.onmessage = (event) => {
const payload = JSON.parse(event.data);
console.log('Received live metrics:', payload.data);
};
socket.onclose = () => {
// WebSockets do not auto-reconnect. You must implement backoff manually.
setTimeout(connect, 3000);
};
socket.onerror = (err) => {
socket.close();
};
}
connect();
Even in a basic implementation, the WebSocket client must supply its own reconnection flow, and neither side has a standardized convention for synchronizing dropped messages.
Practical Decision Matrix: Choosing Based on Data Flow
Choosing a real-time transport is an architectural decision about communication direction, not raw speed.
| Requirement | Recommended Protocol | Why |
|---|---|---|
| Passive metrics / live dashboards | SSE | One-way streaming, native HTTP, built-in reconnection, standard proxy traversal. |
| Notifications & alert feeds | SSE | Server-to-client events with native Last-Event-ID tracking for delivery recovery. |
| Collaborative editing (Docs, Canvas) | WebSockets | Continuous, low-latency bidirectional message passing between clients and server. |
| Chat applications | WebSockets | High-frequency client-to-server messaging where framing overhead matters. |
| Binary streaming (Audio/Video calls) | WebSockets / WebRTC | SSE is text-based (UTF-8). Binary payloads must be Base64-encoded, causing size overhead. |
Conclusion
Real-time web applications do not always need bidirectional pipes. If your application's data flow is one-directional from the server down to the browser defaulting to WebSockets adds connection maintenance and custom reconnection logic without delivering extra utility.
Before wiring up a WebSocket server for your next live feed or dashboard, evaluate the direction of your data. If the client is only listening, Server-Sent Events offer a simpler, HTTP-native alternative that solves reconnection and recovery right out of the box.
Top comments (2)
The Date.now() event ID is the part I'd change before adding replay. Two updates can share a millisecond, and multiple Node processes can publish them out of order. I'd use the durable event log's ordered cursor as the SSE id, then test reconnecting halfway through a burst. That lets Last-Event-ID identify a position in stored history rather than a wall-clock guess. How will your server handle a cursor older than its retained events?
good call splitting reads from writes here, the 'client is a consumer' test is the right way to pick. two things that bit me with SSE: on HTTP/1.1 browsers cap you at 6 connections per domain across tabs, so serve it over HTTP/2, and some proxies buffer the stream unless you send a header like X-Accel-Buffering: no (nginx). also worth sending event ids so EventSource can resume with Last-Event-ID after a drop instead of losing updates