Connection-Count Trade-offs: SSE vs WebSockets Permalink to this section

Part of SSE vs WebSockets vs HTTP Polling.

At 10,000 concurrent users your Node.js process runs out of file descriptors; at 50,000 your Go server’s goroutine stack RSS exceeds the instance memory; at 100,000 your load balancer terminates connections mid-stream with ERR_CONNECTION_RESET. Each failure mode maps directly to how you count and size connections β€” and the protocol you chose (SSE vs WebSocket) changes the math substantially.

Symptom & Developer Intent Permalink to this section

Engineers hitting connection-count ceilings see one or more of these symptoms:

  • EMFILE: too many open files in Node.js logs when the process file-descriptor limit is reached.
  • accept: too many open files in nginx/HAProxy logs when the OS-level ulimit is exhausted.
  • Memory OOM kills: each long-lived WebSocket connection in a framework like Socket.io carries ~40–100 KB of per-socket state; at 10 K connections that is 400 MB–1 GB before any application data.
  • HTTP/2 SSE streams collapsing to a single multiplexed connection per client, then hitting server stream-concurrency caps.

The intent: understand precisely how many file descriptors, goroutines/threads, and bytes of heap each protocol consumes per connected client, so you can right-size your infrastructure and choose the correct protocol for your load profile.

Root Cause Analysis Permalink to this section

File Descriptors Are the Shared Bottleneck Permalink to this section

Both SSE and WebSocket connections consume one TCP socket, which consumes one OS file descriptor (FD). The Linux default per-process FD limit (ulimit -n) is 1,024 on many distros and 65,535 on modern cloud images. The system-wide cap (/proc/sys/fs/file-max) is typically 1–2 million but rarely the first limit you hit.

For SSE over HTTP/1.1: each client opens one persistent TCP connection β†’ one FD on the server process.

For WebSocket: the HTTP/1.1 Upgrade handshake also results in one persistent TCP connection β†’ one FD. FD cost is identical.

The difference emerges in three places:

  1. Per-connection heap overhead: WebSocket libraries (ws, uWebSockets, Gorilla) maintain a bidirectional frame parser, a send buffer, and a receive buffer per socket. SSE servers need only a write buffer and an event channel reference.
  2. HTTP/2 multiplexing: SSE over HTTP/2 allows multiple event streams to share one TCP connection. WebSocket does not run over HTTP/2 (RFC 8441 defines WebSocket bootstrapping over HTTP/2, but browser support is absent as of 2026 and most servers do not implement it).
  3. Proxy and load-balancer slot consumption: WebSocket connections hold a proxy slot for their full lifetime. SSE connections do too β€” but HTTP/2 SSE collapses N streams into one proxy slot.

Per-Connection Memory: Numbers Permalink to this section

Component SSE (HTTP/1.1) SSE (HTTP/2) WebSocket (ws / Gorilla)
OS socket + FD ~4 KB (kernel socket buffer) shared ~4 KB
TLS session ~12–20 KB shared per connection ~12–20 KB
Server read buffer 0 (SSE is write-only) 0 4–16 KB (frame parser)
Server write buffer 4–8 KB 4–8 KB per stream 4–16 KB
App-layer state (typical) 1–4 KB (channel ref, last-event-id) 1–4 KB per stream 8–32 KB (session, room memberships)
Typical total 17–32 KB 5–12 KB (amortised) 28–68 KB

HTTP/2 SSE amortises TLS and TCP overhead across streams from the same client origin. A browser opens one HTTP/2 connection per origin; all EventSource objects on that page share it, each using an HTTP/2 stream (max governed by SETTINGS_MAX_CONCURRENT_STREAMS, default 100).

HTTP/2 Stream Concurrency Caps Permalink to this section

When you use SSE over HTTP/2 you trade per-connection FDs for per-connection stream counts. The server sends a SETTINGS_MAX_CONCURRENT_STREAMS frame; clients that exceed it receive REFUSED_STREAM and must retry.

Key limits in popular HTTP/2 server implementations:

Server / Runtime Default MAX_CONCURRENT_STREAMS Configurable?
h2 (Node.js built-in) 100 Yes, settings.maxConcurrentStreams
nginx (http2 module) 128 http2_max_concurrent_streams
Go net/http + golang.org/x/net/http2 250 http2.Server.MaxConcurrentStreams
Envoy proxy 2147483647 (unlimited) max_concurrent_streams in http2_protocol_options
Cloudflare edge 256 (per connection) Not user-configurable

A single-page app opening 3 EventSource connections (feed, notifications, presence) uses 3 HTTP/2 streams per user. With 250 streams per connection, one HTTP/2 connection handles ~83 such users before the browser opens a second connection β€” but browsers cap concurrent H2 connections to the same origin at 1. Your server therefore needs at most one FD per browser tab for SSE over HTTP/2.

Goroutine and Thread Costs Permalink to this section

Go SSE handlers run in goroutines (~8 KB initial stack, grows as needed). WebSocket handlers (Gorilla) also run in goroutines but are typically held open for bidirectional frames β€” the read loop parks in a blocking call, pinning the goroutine. At 100 K connections, Gorilla WebSocket servers often show 800 MB–2 GB of goroutine stack RSS. SSE write-only loops that block on a channel select cost similar goroutine counts but avoid the read-loop overhead.

Node.js is single-threaded; connections are handled as event-emitter callbacks. The FD and heap are the binding constraints, not threads. ws adds ~20–40 KB per socket beyond the raw TCP cost.

Step-by-Step Resolution Permalink to this section

1. Raise OS File-Descriptor Limits Permalink to this section

# /etc/security/limits.conf  (takes effect on next login / systemd service restart)
*    soft nofile 131072
*    hard nofile 524288
root soft nofile 131072
root hard nofile 524288

# Verify immediately for the running process:
cat /proc/$(pgrep -f your-server)/limits | grep "Max open files"

For systemd-managed services add to the [Service] section:

# /etc/systemd/system/your-app.service
[Service]
LimitNOFILE=524288

Reload with systemctl daemon-reload && systemctl restart your-app.

2. Enable HTTP/2 on Your SSE Server (Node.js) Permalink to this section

Migrating your SSE endpoint to HTTP/2 collapses per-browser FD cost from N (one per EventSource) to 1.

// server.js β€” HTTP/2 secure server with SSE endpoint
import http2 from 'node:http2';
import fs from 'node:fs';

const server = http2.createSecureServer({
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
  settings: {
    maxConcurrentStreams: 512, // per TCP connection; tune for your stream density
  },
});

server.on('stream', (stream, headers) => {
  if (headers[':path'] !== '/events') return stream.respond({ ':status': 404 });

  stream.respond({
    ':status': 200,
    'content-type': 'text/event-stream',
    'cache-control': 'no-cache',
    'x-accel-buffering': 'no',  // disable nginx proxy buffering
  });

  const keepalive = setInterval(() => stream.write(': ping\n\n'), 20_000);

  stream.on('close', () => {
    clearInterval(keepalive);
    // remove from connection registry
  });

  // Push application events
  function send(eventType, payload) {
    if (!stream.destroyed) {
      stream.write(`event: ${eventType}\ndata: ${JSON.stringify(payload)}\n\n`);
    }
  }

  // register `send` with your pub/sub layer here
});

server.listen(443, () => console.log('HTTP/2 SSE server on :443'));

3. Benchmark Per-Connection Memory Before Choosing Protocol Permalink to this section

Run this before committing to either protocol. The numbers for your framework and language runtime matter more than generic benchmarks.

# Start your server, then open N connections with wrk2 (SSE) or wscat (WS)
# Measure RSS growth per connection:

# SSE: open 1000 connections, measure RSS delta
baseline_rss=$(grep VmRSS /proc/$(pgrep -f your-server)/status | awk '{print $2}')

# open 1000 SSE connections in background
for i in $(seq 1 1000); do
  curl -s -N http://localhost:3000/events > /dev/null &
done

sleep 5
peak_rss=$(grep VmRSS /proc/$(pgrep -f your-server)/status | awk '{print $2}')
echo "Per-connection RSS: $(( (peak_rss - baseline_rss) / 1000 )) KB"

# Repeat with WebSocket connections (using wscat or websocat)

4. Set nginx WebSocket / SSE Proxy Timeouts Correctly Permalink to this section

Both protocols need extended proxy timeouts. Without them nginx closes idle connections after proxy_read_timeout (default 60 s).

# /etc/nginx/conf.d/sse-ws.conf
upstream sse_backend {
    server 127.0.0.1:3000;
    keepalive 512;           # idle upstream connections to reuse (SSE-friendly)
}

server {
    listen 443 ssl http2;

    location /events {
        proxy_pass         http://sse_backend;
        proxy_http_version 1.1;
        proxy_set_header   Connection '';          # keep upstream connection alive
        proxy_buffering    off;                    # mandatory for SSE
        proxy_cache        off;
        proxy_read_timeout 86400s;                 # 24 h; clients reconnect via retry:
        add_header         X-Accel-Buffering no;
    }

    location /ws {
        proxy_pass         http://sse_backend;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade    $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

Note: keepalive 512 in the upstream block allows nginx to pool TCP connections to your backend, reducing backend FD churn from short-lived SSE reconnects after the retry interval.

5. Size Your Instance Fleet Using the Scaling Formula Permalink to this section

# scaling_calc.py β€” estimate instances needed for a target concurrency

PROTOCOL = "SSE_HTTP2"  # or "SSE_HTTP1" or "WEBSOCKET"

# Measured per-connection overhead in KB (from step 3)
PER_CONN_KB = {
    "SSE_HTTP2":  8,   # amortised over HTTP/2 multiplexing
    "SSE_HTTP1": 28,
    "WEBSOCKET": 55,
}[PROTOCOL]

TARGET_USERS        = 100_000
INSTANCE_RAM_MB     = 4096
SAFETY_FACTOR       = 0.70   # use 70% of RAM for connections, rest for app heap
FD_LIMIT            = 131_072
CONNS_PER_USER      = 1      # SSE_HTTP2: 1 stream (shared TCP); WS/HTTP1: 1 FD each

# RAM constraint
ram_headroom_kb = INSTANCE_RAM_MB * 1024 * SAFETY_FACTOR
max_conns_by_ram = ram_headroom_kb / PER_CONN_KB

# FD constraint
max_conns_by_fd = FD_LIMIT - 512  # reserve 512 for logs, DB, etc.

max_conns_per_instance = min(max_conns_by_ram, max_conns_by_fd)
instances_needed = TARGET_USERS / max_conns_per_instance * CONNS_PER_USER

print(f"Protocol: {PROTOCOL}")
print(f"Max conns/instance (RAM):  {int(max_conns_by_ram):,}")
print(f"Max conns/instance (FD):   {int(max_conns_by_fd):,}")
print(f"Binding constraint:         {'RAM' if max_conns_by_ram < max_conns_by_fd else 'FD'}")
print(f"Instances needed:           {instances_needed:.1f}")

Run it for all three protocol configurations before provisioning. SSE over HTTP/2 almost always binds on RAM; WebSocket frequently binds on both.

Validation & Monitoring Permalink to this section

Check FD Usage in Real Time Permalink to this section

# FDs currently open by your server process
ls /proc/$(pgrep -f your-server)/fd | wc -l

# Continuous watch β€” alert when > 80% of limit
watch -n 5 'echo "FDs: $(ls /proc/$(pgrep -f your-server)/fd | wc -l) / $(cat /proc/$(pgrep -f your-server)/limits | grep "Max open files" | awk '"'"'{print $4}'"'"')"'

Verify HTTP/2 Multiplexing Permalink to this section

# Confirm server speaks HTTP/2 and negotiates h2
curl -vso /dev/null --http2 https://yourdomain.com/events 2>&1 | grep -E "^(< HTTP|\\* Using HTTP)"
# Expected:
# * Using HTTP2, server supports multiplexing
# < HTTP/2 200

Monitor Stream Count per Connection (Go example) Permalink to this section

// metrics.go β€” expose active SSE stream count via Prometheus
package main

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var activeSSEStreams = promauto.NewGauge(prometheus.GaugeOpts{
    Name: "sse_active_streams_total",
    Help: "Number of active SSE streams (HTTP/2 stream or HTTP/1.1 connection).",
})

// In your SSE handler:
//   activeSSEStreams.Inc()
//   defer activeSSEStreams.Dec()

Alert when sse_active_streams_total / instance_count exceeds 80% of your MAX_CONCURRENT_STREAMS setting.

For connection pooling at scale, also monitor net.conntrack_dialer_conn_established_total and kernel-level ss -s output.


⚑ Production Directives

  • Set LimitNOFILE=524288 in the systemd unit; the default 1,024 limits you to ~900 connections after OS and app overhead.
  • Deploy SSE endpoints over HTTP/2 β€” it collapses per-browser FD cost from N (one per EventSource object) to 1 TCP connection, cutting server FD consumption by 3–10Γ— for typical SPAs.
  • Measure per-connection RSS with a live load test on your exact framework and language version β€” published benchmarks vary Β±3Γ— from real-world numbers.
  • Alert when active stream count exceeds 80% of MAX_CONCURRENT_STREAMS Γ— expected_connections; at 100% the server starts refusing streams silently.
  • Set proxy_buffering off and proxy_read_timeout 86400s in nginx/HAProxy; missing either kills SSE streams within 60 seconds.

Frequently Asked Questions Permalink to this section

Does SSE use fewer file descriptors than WebSockets?

Over HTTP/1.1, SSE and WebSocket consume exactly one FD per client β€” the difference is zero. The advantage appears with HTTP/2: multiple EventSource objects on the same page share one TCP connection (one FD), while WebSocket has no HTTP/2 multiplexing support in browsers as of 2026, so each WebSocket still requires its own FD.

What is the practical connection limit per Node.js process?

With ulimit -n 131072 and ~28 KB per SSE connection (HTTP/1.1), a 4 GB instance can hold roughly 100,000 connections before RAM binds first (~2.8 GB). FD limit binds at ~130,000. In practice, target 60–70% of the lower limit to leave headroom for GC spikes and log file descriptors.

Why does HTTP/2 SSE help with proxies?

HTTP/2 SSE multiplexes streams inside one TCP connection, so a load balancer or CDN edge holds one upstream slot per client instead of N. This reduces both the proxy's FD consumption and the backend's accept-queue pressure during reconnect storms β€” for example, after a retry interval fires and thousands of clients reconnect simultaneously.

How do I pick MAX_CONCURRENT_STREAMS for my SSE server?

Divide the expected streams-per-client by the fraction of clients sharing one HTTP/2 connection (usually 1 for browser clients). A typical SPA opens 2–4 EventSource objects, so set MAX_CONCURRENT_STREAMS to at least 16 to leave room for other in-flight requests. For API servers behind a single shared H2 connection (e.g., a backend service), set it to the maximum simultaneous subscriptions that service will hold, typically 50–200.

Can I use WebSockets and SSE together in the same service?

Yes. A common pattern is SSE for broadcast / server-push feeds (cheaper FD and memory cost at scale) and WebSockets for bidirectional channels like chat or collaborative editing. Both can share an nginx upstream and a single backend port. Size the FD limit for the sum of both connection types and track them separately in your metrics β€” see the scaling math in this guide for the combined formula.