DEV Community

Rahul Mehta
Rahul Mehta

Posted on

Stop Defaulting to WebSockets: Why I Switched to SSE for Live Dashboards

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}

Enter fullscreen mode Exit fullscreen mode

Because SSE is standard HTTP, it fits cleanly into existing infrastructure:

  1. 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).
  2. 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.
  3. Native Browser Support: The browser's native EventSource interface 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:

  1. The server tags each outgoing event with an id: value.
  2. When the connection drops, the browser retains the last ID it processed.
  3. Upon reconnecting, the browser automatically sends that ID in the Last-Event-ID HTTP header.
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
marcusykim profile image
Marcus Kim •

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?

Collapse
 
susanacharya profile image
Susan Acharya •

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