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 filesin Node.js logs when the process file-descriptor limit is reached.accept: too many open filesin nginx/HAProxy logs when the OS-levelulimitis 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:
- 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.
- 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).
- 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=524288in 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 offandproxy_read_timeout 86400sin 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.