Web Development

Server-Sent Events vs WebSockets: Picking the Right One for a Real-Time Feature

A live notifications feature got built on WebSockets by default, then rebuilt on Server-Sent Events six weeks later — the specific reasons the simpler option turned out to be the right one.

By Aissam Ait Ahmed Web Development 0 comments

A live notifications feature — a small badge that updates when a background job finishes — got built on WebSockets first, mostly because WebSockets was the familiar default answer to "how do I push real-time updates to a browser." Six weeks later it was rebuilt on Server-Sent Events instead, and the badge has worked more reliably since the rebuild than it ever did on the original implementation. The reasons are specific enough to be worth walking through rather than defaulting to WebSockets the next time a similar feature comes up.

What each technology actually does, in plain terms

WebSockets open a persistent, bidirectional connection — the browser and server can both send messages at any time, over the same open connection, which makes it the right fit for anything requiring the browser to send frequent updates back (a collaborative editor, a chat application, a multiplayer game). Server-Sent Events open a persistent, one-directional connection — the server pushes updates to the browser over a plain HTTP connection, and the browser has no equivalent channel to push data back over that same connection. For a feature that's purely "server tells browser something changed," that directional limitation isn't actually a limitation at all; it's a closer match to what the feature needs.

Why WebSockets got chosen first, and why that reasoning didn't hold up

The initial reasoning was simple and, on reflection, not particularly well examined: WebSockets are the more general-purpose, more capable technology, so building on WebSockets first meant not having to reconsider the choice if the feature later needed bidirectional communication. That reasoning optimizes for a hypothetical future requirement at the cost of real, immediate complexity — WebSockets need their own connection management, reconnection logic, and (depending on the infrastructure) a separate server process or a load balancer configured specifically to support persistent connections, none of which a plain HTTP-based approach needs. The notifications feature never grew a need for the browser to push anything back over that channel, and six weeks of maintaining WebSocket-specific infrastructure for a feature that only ever used one direction of it was the direct cost of solving for a requirement that never actually materialized.

The specific reliability problems that showed up in production

  • Reconnection after network interruption was inconsistent — mobile users switching between WiFi and cellular lost their WebSocket connection regularly, and the client-side reconnection logic, hand-rolled rather than provided by a library, had edge cases where it silently failed to reconnect, leaving the notification badge stale until a full page reload.
  • Corporate proxies and some VPNs blocked or degraded WebSocket connections for a small but real fraction of users, something that only became visible once support tickets started mentioning notifications "just not showing up," traced eventually to network environments that didn't handle the WebSocket upgrade handshake cleanly.
  • Load balancer configuration needed ongoing, WebSocket-specific attention — sticky sessions had to be configured correctly for the persistent connection to route consistently to the same backend process, an infrastructure detail that a plain request-response endpoint doesn't need to think about at all.

What the Server-Sent Events rebuild actually looked like

// Server: a plain HTTP endpoint that stays open and streams events
Route::get('/notifications/stream', function () {
    return response()->stream(function () {
        while (true) {
            if ($update = Notifications::pollForUser(auth()->id())) {
                echo "data: " . json_encode($update) . "\n\n";
                ob_flush();
                flush();
            }
            usleep(500000); // poll every 500ms
        }
    }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache']);
});

// Client: built into the browser, no library needed
const source = new EventSource('/notifications/stream');
source.onmessage = (event) => updateBadge(JSON.parse(event.data));

The most immediately noticeable difference was how much client-side code disappeared: no manual reconnection logic, because EventSource reconnects automatically on its own by default, using the browser's native retry behavior rather than a hand-rolled equivalent that had its own bugs. No separate WebSocket-aware load balancer configuration, because Server-Sent Events ride over plain HTTP, the same protocol every other request on the site already uses, which the existing infrastructure already handled correctly without any special-casing.

The Nginx buffering gotcha that made the first deploy look broken

The rebuild worked perfectly in local development and then appeared completely broken the moment it hit staging — the connection opened, the browser's network tab showed it as pending exactly as expected, and no events ever actually arrived, for minutes at a time, until the connection eventually timed out. The cause turned out to be Nginx, sitting in front of the application server, buffering the response by default before forwarding any of it to the browser — entirely reasonable behavior for a normal HTTP response, and exactly wrong for a stream that's supposed to deliver small chunks of data as they become available rather than all at once at the end. The fix was a single response header, easy to miss in most SSE tutorials that assume a bare development server with no reverse proxy in front of it:

return response()->stream(function () {
    // ...streaming logic...
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no', // tells Nginx specifically not to buffer this response
]);

Worth calling out because it's the kind of gotcha that costs real debugging time precisely because everything looks correct at every layer checked individually — the PHP code was streaming correctly, the browser was requesting correctly, and the only thing wrong was a proxy layer neither the client-side code nor the application code had any visibility into. Anyone deploying Server-Sent Events behind Nginx, Apache, or a CDN should check that layer's buffering behavior specifically, rather than assuming an SSE implementation that works in local development will behave identically once a reverse proxy sits in front of it.

Where the corporate-proxy problem actually went, honestly

Worth stating plainly rather than glossing over: Server-Sent Events didn't fully solve the proxy and network-environment problem, it mostly avoided it, because a plain HTTP connection that stays open is generally handled better by intermediate network infrastructure than a WebSocket upgrade handshake is, not because it's immune to every possible network issue. A small number of the same reliability complaints that showed up under WebSockets did not fully disappear under SSE, but the frequency dropped meaningfully, and the automatic reconnection behavior meant the ones that did still occur resolved themselves within a few seconds rather than requiring a manual page reload the way the hand-rolled WebSocket reconnection logic often had.

When WebSockets are actually the right choice, based on this experience

None of this is an argument that WebSockets are the wrong technology generally — a genuinely bidirectional real-time feature, where the browser needs to send frequent updates back over the same open connection rather than making occasional separate requests, is exactly the case WebSockets are built for, and Server-Sent Events can't do that job at all since the direction is fundamentally one-way. The mistake wasn't choosing WebSockets as a technology; it was choosing them for a feature that never actually needed the bidirectional capability, based on a vague sense that the more capable tool was the safer default, rather than matching the tool to what the specific feature actually required.

ConsiderationServer-Sent EventsWebSockets
DirectionServer → browser onlyBidirectional
ReconnectionAutomatic, built into the browserMust be implemented manually
InfrastructurePlain HTTP, no special configOften needs sticky sessions / dedicated config
Proxy/firewall friendlinessGenerally better, still not guaranteedMore frequently blocked or degraded
Best fitNotifications, live feeds, status updatesChat, collaborative editing, multiplayer features

How to actually decide, going forward

The question that should have been asked at the start, and now gets asked explicitly for any new real-time feature: does the browser genuinely need to send frequent updates back over the same persistent connection, or does it just need to receive updates and can make an occasional separate request for anything it needs to send? If it's the second case — which covers notifications, live dashboards, status badges, and most "something changed, update the UI" features — Server-Sent Events is very likely the better starting point, with meaningfully less infrastructure and client-side complexity for the same practical result. The habit of matching a caching or delivery mechanism to what a feature specifically needs, rather than to the most general-purpose tool available, is the same instinct covered from a different angle in Cache-Control headers explained by breaking them on purpose — in both cases, the general-purpose, more powerful-sounding option carries real complexity that's only worth paying for when the feature actually exercises the capability that complexity exists to support.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools