Server-Sent Events for Real-Time Notifications: Why I Didn't Reach for WebSockets

By James Nguyen Updated September 24, 2026
Server-Sent Events for Real-Time Notifications: Why I Didn't Reach for WebSockets

A notification bell needing to update in real time as new notifications arrived was the kind of feature where my first instinct was to reach for WebSockets, since that's the default answer for "real time" in most people's mental model. The actual requirement, though, was one-directional, server to client only, the client never needed to push anything back over the same channel, and Server-Sent Events turned out to be a simpler, more appropriate tool for exactly that shape of problem, with less code and fewer moving parts than a WebSocket implementation would have needed.

The Actual Difference: Bidirectional vs One-Directional

WebSockets establish a full-duplex connection, both sides can send messages at any time over the same socket, which is the right tool when the client genuinely needs to push data back, a chat application, a collaborative editor. SSE is deliberately one-directional, the server streams events to the client over a plain HTTP connection, and there's no matching send-from-client mechanism built into the protocol at all. For a notification feed, the client only ever needs to receive, so the extra bidirectional machinery a WebSocket provides was overhead the feature never used.

SSE Runs Over Plain HTTP, Which Simplifies the Infrastructure

A WebSocket connection requires an HTTP upgrade handshake and then behaves as a different protocol afterward, which some older proxies, load balancers, and corporate firewalls handle inconsistently. SSE is just a long-lived HTTP response with a specific content type, text/event-stream, that stays open and streams data, which meant it passed through our existing infrastructure, load balancer included, without any special WebSocket-aware configuration anywhere in the chain.

The Server Side Is Genuinely Simple

An SSE endpoint sets the right headers, keeps the response open, and writes formatted event strings to it whenever there's something to send. No separate protocol library is needed on the server, since it's plain HTTP with a specific text format for each event, terminated by a blank line.

app.get('/notifications/stream', authenticate, (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });
  res.flushHeaders();

  const send = (event, data) => {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  };

  const unsubscribe = notificationBus.subscribe(req.user.id, (notification) => {
    send('notification', notification);
  });

  // Heartbeat comment line — keeps intermediary proxies from timing out the connection
  const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 20000);

  req.on('close', () => {
    clearInterval(heartbeat);
    unsubscribe();
  });
});

The Browser's EventSource API Handles Reconnection for You

This is the detail that actually tipped the decision. The browser's built-in EventSource API automatically reconnects if the connection drops, with a browser-managed retry delay, and automatically resumes from the last received event id if the server sends one, none of which needed custom client-side code the way the WebSocket reconnection logic I'd built for a different feature required. For a notification feed where missing a beat isn't catastrophic, this built-in behavior alone eliminated an entire category of client-side reconnection code.

const source = new EventSource('/notifications/stream');

source.addEventListener('notification', (event) => {
  const notification = JSON.parse(event.data);
  showNotificationBadge(notification);
});

source.onerror = () => {
  // EventSource retries automatically; this just logs for visibility
  console.warn('notification stream disconnected, browser will retry');
};

Resuming After a Reconnect Without Losing Notifications

Sending an id: field with each event lets EventSource automatically include a Last-Event-ID header on reconnection attempts, which the server reads and uses to replay anything the client missed during the gap, the same sequence-based replay idea that matters for WebSocket reconnection too, just handled with less custom client code since the browser manages sending the header itself rather than needing that logic hand-written.

The Real Limitation: Browser Connection Limits Per Origin

HTTP/1.1 browsers cap concurrent connections per origin at a small number, historically six, which matters if a page opens several SSE streams to the same origin alongside regular API requests competing for the same connection pool. This wasn't a problem for us since the notification stream was the only long-lived connection on the page, but it's a real constraint worth checking against your actual page's connection usage; serving over HTTP/2, which multiplexes many streams over one connection, removes this limit entirely if it does become a problem.

When I Would Actually Reach for WebSockets Instead

The moment a feature needs the client to send data back over the same real-time channel, live cursor positions in a collaborative tool, a chat message, presence updates the client itself is generating, SSE isn't the right tool, the client side would need a separate regular HTTP request for that anyway, at which point a WebSocket's genuine bidirectionality is doing real work instead of being unused capacity.

Final Verdict

For a notification feed, or any other server-to-client-only real-time feature, SSE delivered everything the requirement actually needed, automatic reconnection, event replay via Last-Event-ID, plain HTTP infrastructure compatibility, with meaningfully less code than a WebSocket implementation covering the same feature would have needed. Reaching for WebSockets by default because it's the more familiar "real time" answer would have been solving a bidirectional problem the feature never actually had.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles