Programming

WebSocket vs Server-Sent Events: Which Protocol Scales Best?

Tech Setup7 min read
TS

Tech Setup

Published October 1, 2026 · Editorial policy

WebSocket vs Server-Sent Events: Which Protocol Scales Best?

You are building a real-time analytics dashboard for a European fintech platform, and your users are screaming about stale data while your server CPU sits at 90% utilization. Choosing the wrong transport layer between WebSockets and Server-Sent Events (SSE) at this stage means either rewriting your infrastructure in three months or burning money on cloud egress fees. This guide cuts through the protocol theory to give you a concrete decision framework, implementation blueprints, and scaling benchmarks for production apps.

The Architectural Divide: Bidirectional vs. Unidirectional

Before writing a single line of code, you need to understand how these protocols interact with your network stack. The fundamental architectural difference is simple: WebSockets are fully bidirectional over a single TCP connection, while Server-Sent Events are unidirectional, pushing data from server to client over standard HTTP.

How WebSockets Work Under the Hood

A WebSocket connection begins as an HTTP/1.1 GET request with specific upgrade headers. Once the handshake succeeds, the HTTP protocol is discarded, and the connection switches to the WebSocket framing protocol (RFC 6455).

GET /ws/v1/metrics HTTP/1.1
Host: api.techsetup.dev
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

The server responds with a 101 Switching Protocols status. From that point forward, data flows in frames with minimal overhead (as low as 2 to 10 bytes per frame). Because it operates outside standard HTTP semantics, you lose built-in features like automatic caching, standard load-balancer health checks, and native HTTP/2 multiplexing. You must implement your own heartbeat (ping/pong) mechanisms to detect dead connections behind corporate proxies.

How Server-Sent Events Work Under the Hood

SSE operates entirely within standard HTTP/1.1 or HTTP/2. The client opens a standard GET request, setting the Accept header to text/event-stream. The server keeps the connection open indefinitely, streaming UTF-8 encoded text data formatted according to the SSE specification.

GET /api/v1/metrics/stream HTTP/1.1
Host: api.techsetup.dev
Accept: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

The server responds with a continuous stream:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"cpu": 42.1, "memory": 78.4}

data: {"cpu": 39.8, "memory": 78.5}

Because SSE is plain HTTP, it automatically benefits from HTTP/2 multiplexing (sending multiple event streams over a single TCP connection), standard TLS termination, and standard corporate proxy handling. If the connection drops, the browser's EventSource API automatically attempts to reconnect and sends the last received event ID via the Last-Event-ID header, eliminating custom reconnection boilerplate.

Implementation Blueprint: Node.js & Vanilla JS

Let's build a concrete implementation of both protocols to compare their code complexity. We will stream simulated server metrics to a frontend client.

Server-Sent Events Implementation

Initialize a minimal Node.js project using Express to serve the SSE stream.

mkdir sse-demo && cd sse-demo
npm init -y
npm install express

Create a file named server-sse.js:

const express = require('express');
const app = express();

app.get('/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders(); // Establish the streaming response

  // Send an event every 2 seconds
  const intervalId = setInterval(() => {
    const payload = JSON.stringify({ timestamp: Date.now(), load: Math.random() * 100 });
    res.write(`data: ${payload}\n\n`);
  }, 2000);

  // Clean up when client drops connection
  req.on('close', () => {
    clearInterval(intervalId);
    res.end();
  });
});

app.listen(3000, () => {
  console.log('SSE server running on http://localhost:3000');
});

Run the server:

node server-sse.js

Expected Output: SSE server running on http://localhost:3000

On the client side, consume this stream with zero external libraries:

const evtSource = new EventSource('http://localhost:3000/stream');

evtSource.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log('Metric received:', data);
};

evtSource.onerror = function(err) {
  console.error('EventSource failed:', err);
};

WebSocket Implementation

Now let's build the equivalent WebSocket service using the ws package.

mkdir ws-demo && cd ws-demo
npm init -y
npm install ws

Create a file named server-ws.js:

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Client connected');

  const intervalId = setInterval(() => {
    if (ws.readyState === ws.OPEN) {
      const payload = JSON.stringify({ timestamp: Date.now(), load: Math.random() * 100 });
      ws.send(payload);
    }
  }, 2000);

  ws.on('message', (message) => {
    console.log(`Received: ${message}`);
  });

  ws.on('close', () => {
    clearInterval(intervalId);
    console.log('Client disconnected');
  });
});

console.log('WebSocket server running on ws://localhost:8080');

Run the WebSocket server:

node server-ws.js

Expected Output: WebSocket server running on ws://localhost:8080

Consume it on the client:

const ws = new WebSocket('ws://localhost:8080');

ws.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log('WS Metric received:', data);
};

ws.onerror = function(err) {
  console.error('WebSocket error:', err);
};

Scaling and Production Infrastructure

When your user base grows from hundreds to hundreds of thousands, network topology becomes your primary bottleneck. This is where WebSockets and SSE diverge dramatically in infrastructure cost and operational complexity.

Load Balancing and Sticky Sessions

WebSockets maintain a persistent TCP connection. If you deploy your app across multiple Kubernetes pods behind a standard Layer 7 load balancer (like AWS ALB or Nginx), you must enable sticky sessions (session affinity). If a client reconnects, they must hit the exact same pod to maintain state, which creates uneven resource distribution across your cluster. Furthermore, if you need to broadcast a message to a user connected to Pod A from an event handled on Pod B, you need an external pub/sub layer like Redis to bridge the gap.

SSE drastically simplifies this architecture. Because SSE runs over standard HTTP/1.1 or HTTP/2, any standard load balancer can distribute incoming requests across any available pod. If a connection drops, the next HTTP request hits any pod in the cluster seamlessly. Scaling horizontally requires zero custom sticky-session configurations.

Connection Limits and File Descriptors

Every open connection consumes a socket file descriptor on your operating system. Linux limits the number of open files per process via nofile.

If you run a server handling 50,000 concurrent WebSocket or SSE connections, you must tune your kernel parameters. Check your current limits:

ulimit -n

To increase limits permanently for production deployments, edit /etc/security/limits.conf:

www-data   soft   nofile   100000
www-data   hard   nofile   100000

And update your systemd service file (/etc/systemd/system/myapp.service) to allow high file limits:

[Service]
LimitNOFILE=100000

Protocol Overhead Comparison

MetricServer-Sent Events (SSE)WebSockets
DirectionalityUnidirectional (Server to Client)Bidirectional (Full duplex)
TransportStandard HTTP (HTTP/1.1 or HTTP/2)TCP via custom framing protocol
ReconnectionAutomatic (Built into browser API)Manual implementation required
MultiplexingNative over HTTP/2Requires separate TCP ports/connections
Firewall/ProxyingPasses through corporate HTTP proxies easilyOften blocked or heavily inspected by corporate firewalls

Decision Framework: Which One Should You Pick?

Do not default to WebSockets just because they are more famous. Apply this strict evaluation checklist to your project requirements.

Choose Server-Sent Events If:

  1. Data flows strictly one way: Your dashboard, live feed, notification ticker, or price tracker only needs updates pushed from the server to the browser. Client actions go through standard REST/GraphQL mutations.
  2. You need bulletproof reliability out of the box: You cannot afford to spend two sprints writing custom reconnection logic, heartbeat ping/pong wrappers, and offline queueing.
  3. You operate behind strict corporate firewalls: Your users are enterprise B2B clients whose IT departments block non-standard WebSocket handshakes or drop idle TCP connections after 60 seconds. SSE works over port 443 via standard HTTPS.
  4. You want HTTP/2 multiplexing: You want multiple event streams to share a single TCP connection alongside your standard asset and API requests without exhausting browser connection limits (which cap HTTP/1.1 connections at 6 per domain).

Choose WebSockets If:

  1. You require low-latency, high-frequency bidirectional streaming: You are building a multiplayer collaborative editor, a real-time chat application, a trading terminal with split-second order execution, or an online multiplayer game where both client and server send continuous high-frequency payloads.
  2. Binary data transfer is critical: You need to transmit raw ArrayBuffers, binary blobs, or custom compressed protocol buffers without paying the CPU tax of base64 encoding them into text strings.

Troubleshooting Common Production Failures

When real-time streaming fails in production, it is rarely the application code—it is the networking layer. Use this checklist to debug your implementation.

The Connection Drops Every 60 Seconds

  • The Root Cause: An intermediate proxy, load balancer (such as AWS ELB), or corporate firewall is timing out idle TCP connections.
  • The Fix: Implement an explicit heartbeat interval. For SSE, send a comment line :ping\n\n every 15 seconds. For WebSockets, configure your server library to send a ping frame and terminate unresponsive connections.

Browser Connection Limits Are Exhausted

  • The Root Cause: You are using HTTP/1.1, and the browser has hit its hard limit of 6 simultaneous connections to a single domain. Opening multiple tabs or widgets locks out subsequent API requests.
  • The Fix: Enable HTTP/2 on your ingress controller or reverse proxy (Nginx, Caddy, Envoy). HTTP/2 multiplexes unlimited SSE streams over a single TCP connection, resolving the starvation issue entirely.

Out of Memory (OOM) Errors on the Node.js Server

  • The Root Cause: Unbounded event emitters or failure to clean up closed connections. When clients disconnect abruptly without sending a TCP FIN packet, the server's event loop may retain references to the response objects.
  • The Fix: Always listen to the close or aborted request event on the server, explicitly clear intervals, and remove event listeners from global state stores.

Alternative Approaches: When to Avoid Persistent Connections Entirely

Before committing to long-lived TCP or HTTP streams, evaluate whether your use case actually requires real-time streaming.

If your dashboard updates occur every 30 seconds or longer, discard WebSockets and SSE entirely. Implement standard HTTP Polling with Conditional Requests using ETag and If-None-Match headers. This approach keeps your architecture stateless, allows aggressive caching via CDNs (Cloudflare, AWS CloudFront), eliminates memory leaks from dangling socket connections, and scales naturally with zero infrastructure modifications. Reserve persistent connections strictly for sub-second, event-driven requirements.