SSE gets dismissed a lot. "Just use WebSockets," people say, as if the extra handshake complexity, bidirectional overhead, and proxy headaches are free. SSE is HTTP — your existing reverse proxies, load balancers, auth middleware, and CDNs already understand it. That’s not a consolation prize; it’s a genuine architectural advantage for push-only data streams like live dashboards, AI token streaming, log tails, and notification feeds.
The catch is that SSE is deceptively easy to get wrong at scale. The protocol itself is six lines of spec. The operational reality is a different story. Slow clients, file descriptor exhaustion, reverse proxy timeouts, and missing backpressure will quietly destroy you. This article is about the parts nobody writes up until after the incident.
What You’re Actually Dealing With
Each SSE connection is a long-lived HTTP response that never closes — the server writes chunks, the client reads them. From the OS perspective, that’s one open file descriptor per connected client. From the app server perspective, that’s one goroutine, thread, or async handle per client. From the proxy perspective, that’s one upstream keepalive slot.
All of these have limits, and none of the defaults are set for SSE workloads.
Before going further: the official spec lives in the WHATWG HTML standard. Most of what breaks in production isn’t in the spec — it’s in the gap between the spec and how real infrastructure handles persistent HTTP responses.
The File Descriptor Problem
This hits Linux first. The default ulimit -n on most distros is 1024 per process. If your Node or Python process tries to hold 2000 SSE connections, it dies. Loudly.
Check your current limit:
# Check the hard and soft limits for the current shell
ulimit -Hn # hard limit
ulimit -Sn # soft limit
# Check what a running process actually sees
cat /proc/$(pgrep -n node)/limits | grep "open files"
Fix it properly — not just for your shell, but for the service:
# /etc/security/limits.conf
# Replace 'appuser' with your service's OS user
appuser soft nofile 65535
appuser hard nofile 65535
If you’re running via systemd (and you should be):
# /etc/systemd/system/your-app.service
[Service]
LimitNOFILE=65535
For Docker Compose:
services:
app:
image: your-app:latest
ulimits:
nofile:
soft: 65535
hard: 65535
Gotcha: Setting nofile in /etc/security/limits.conf does nothing for systemd-managed services. The PAM session limit and the systemd limit are separate. I’ve watched engineers spend two hours on this one.
Nginx in Front of SSE: Where It Goes Wrong
Nginx is the most common reverse proxy in front of SSE backends, and its defaults are hostile to long-lived connections.
Problem 1: Buffering. By default, nginx buffers proxy responses. For SSE, this means events pile up in nginx’s buffer and only get flushed to the client in chunks or when the buffer fills. Your "real-time" feed becomes a batch feed.
Problem 2: Timeouts. proxy_read_timeout defaults to 60 seconds. An idle SSE connection (waiting for the next event) gets killed after 60 seconds. Your client reconnects, and you have a thundering herd every minute.
Problem 3: Connection limits. worker_connections defaults to 1024 per worker. With four workers, that’s 4096 total connections including all other traffic.
Here’s a production-grade nginx config for SSE:
# nginx.conf — top level
worker_processes auto;
events {
# Set high — each SSE client holds one connection
worker_connections 32768;
use epoll;
multi_accept on;
}
http {
# Keep connections open to upstream (your app server)
upstream sse_backend {
server 127.0.0.1:3000;
keepalive 256; # maintain this many idle keepalive connections to upstream
}
server {
listen 80;
location /events {
proxy_pass http://sse_backend;
# CRITICAL: disable buffering — events must flow immediately
proxy_buffering off;
proxy_cache off;
# SSE requires chunked transfer encoding to stay open
proxy_http_version 1.1;
proxy_set_header Connection ''; # don't pass "close" to upstream
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Increase timeouts — idle SSE streams will hit the default
# Use a heartbeat on the server side and set this to > heartbeat interval
proxy_read_timeout 3600s; # 1 hour
proxy_send_timeout 3600s;
# Required headers for SSE
add_header Content-Type text/event-stream;
add_header Cache-Control no-cache;
add_header X-Accel-Buffering no; # also tells nginx not to buffer
}
}
}
Gotcha: X-Accel-Buffering: no can be set both in nginx config and as a response header from your upstream. Setting it from the app is often more reliable because it travels with the response regardless of which nginx config is active. Add it in your app code: res.setHeader('X-Accel-Buffering', 'no').
Backpressure: The Slow Client Problem
This is the one that kills you silently.
Backpressure is what happens when your server produces events faster than a client can consume them. In a classic message queue, you pause the producer. In SSE, your producer is often a shared event bus — you can’t pause it for one slow client without affecting everyone.
What happens in practice: the OS TCP send buffer fills up for the slow client’s socket. Your app write call blocks (or in async code, the internal write queue grows unboundedly). Memory climbs. Eventually your process OOMs or grinds to a halt serving that one dial-up user.
The Wrong Way
The most common mistake is fire-and-forget publishing:
// BAD: no awareness of whether the client can receive
function publishEvent(clients, data) {
for (const res of clients) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
If res.write blocks or the internal queue is full, you’ve just stalled your event loop (Node) or blocked a goroutine.
The Right Way: Drain Events + Write Queues
In Node.js, writable streams expose a drain event and write() returns false when the buffer is full:
class SSEClient {
constructor(res) {
this.res = res;
this.queue = [];
this.draining = false;
res.on('close', () => {
this.closed = true;
});
res.on('drain', () => {
this.draining = false;
this._flush();
});
}
send(data) {
if (this.closed) return;
this.queue.push(`data: ${JSON.stringify(data)}\n\n`);
this._flush();
}
_flush() {
if (this.draining) return;
while (this.queue.length > 0) {
const chunk = this.queue.shift();
const ok = this.res.write(chunk);
if (!ok) {
// Kernel buffer full — wait for drain
this.draining = true;
break;
}
}
}
// Call this to cap the queue and drop old events for slow clients
get queueDepth() {
return this.queue.length;
}
}
Add a queue depth cap. If a client’s queue grows past, say, 100 events, they’re too slow — either drop the oldest events or disconnect them:
send(data) {
if (this.closed) return;
if (this.queue.length > 100) {
// Option 1: drop the oldest (for live feeds, latest is what matters)
this.queue.shift();
// Option 2: disconnect the client entirely
// this.res.end();
// this.closed = true;
// return;
}
this.queue.push(`data: ${JSON.stringify(data)}\n\n`);
this._flush();
}
The right choice depends on your data semantics. For market data or live scores, drop old events — the client wants current state. For audit logs or ordered command streams, disconnect the slow client and make them re-subscribe with a Last-Event-ID to catch up.
Heartbeats: Keeping Connections Alive
Idle SSE connections get killed by proxies, load balancers, and NAT gateways. The fix is a server-side heartbeat — a comment line (starting with :) that carries no data but keeps the TCP connection alive:
// Send a heartbeat comment every 25 seconds
// Set this lower than any proxy timeout in your chain
function startHeartbeat(res) {
const interval = setInterval(() => {
if (res.writableEnded) {
clearInterval(interval);
return;
}
res.write(': heartbeat\n\n');
}, 25000);
res.on('close', () => clearInterval(interval));
}
Gotcha: The heartbeat interval must be shorter than the shortest timeout in your entire proxy chain. If nginx has proxy_read_timeout 60s, heartbeat every 25–30s. If a CDN sits in front with a 30s idle timeout, heartbeat every 15–20s. Check every hop.
Reconnection and Last-Event-ID
SSE has built-in reconnection — the browser automatically reconnects after a connection drops. The Last-Event-ID header tells the server the last event the client successfully received, so you can replay missed events.
Server-side, assign IDs to events and implement a replay buffer:
import asyncio
import time
from collections import deque
# Circular buffer of recent events — keep last N for replay
event_buffer = deque(maxlen=500)
event_counter = 0
def emit_event(data: dict) -> dict:
global event_counter
event_counter += 1
event = {
"id": event_counter,
"data": data,
"ts": time.time()
}
event_buffer.append(event)
return event
def events_since(last_id: int):
"""Return events the client missed since last_id."""
return [e for e in event_buffer if e["id"] > last_id]
async def sse_handler(request):
last_id = int(request.headers.get("Last-Event-ID", 0))
response = web.StreamResponse()
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
await response.prepare(request)
# Replay missed events before entering live stream
for event in events_since(last_id):
payload = f"id: {event['id']}\ndata: {json.dumps(event['data'])}\n\n"
await response.write(payload.encode())
# Now stream live events...
async for live_event in subscribe_to_live_feed():
payload = f"id: {live_event['id']}\ndata: {json.dumps(live_event['data'])}\n\n"
await response.write(payload.encode())
return response
Set the retry: field to control how long the browser waits before reconnecting:
retry: 3000\n\n # reconnect after 3 seconds
Gotcha: The browser caps reconnect backoff, but not at a reasonable value everywhere. For mobile clients on flaky connections, they’ll hammer your server with reconnects. Rate-limit reconnects per IP at the nginx level.
Load Balancing SSE
Standard round-robin load balancing breaks SSE replay. If a client reconnects and lands on a different backend, that backend doesn’t have the client’s missed events.
Your options:
1. Sticky sessions. Route each client to the same backend based on IP hash or a cookie. Simple, works, but means one failing backend disconnects all its clients simultaneously.
upstream sse_backend {
ip_hash; # simple stickiness by client IP
server backend1:3000;
server backend2:3000;
server backend3:3000;
}
2. Shared event log. Store events in Redis Streams or Kafka with a global sequence number. Any backend can replay for any client. More infrastructure, but proper horizontal scaling.
# Redis Streams — add an event
XADD events '*' type "user_update" payload '{"id":42}'
# Read events after a given ID (for replay)
XRANGE events 1748000000000-0 +
3. Accept the gap. For pure live feeds where historical events don’t matter (think live metrics or presence indicators), just reconnect to any backend and start fresh. Drop Last-Event-ID on the server side. Simpler, works for the right use case.
Connection Limit Math
Before you deploy, work out whether your infrastructure can physically hold the expected connection count.
A rule of thumb per connection:
- Kernel: 1 file descriptor, ~4KB TCP socket buffer (configurable), ~1KB kernel struct
- Nginx (as proxy): 1 upstream connection + 1 downstream connection = 2 FDs per SSE client
- App (Node.js): ~25–50KB per open HTTP response object, plus your event queue
- App (Go): ~8KB goroutine stack, grows as needed
For 10,000 concurrent SSE clients through nginx:
- nginx needs
worker_connections≥ 20,000 (2 per client) - nginx process needs
nofile≥ 20,000 - App needs
nofile≥ 10,000 - At 50KB/connection in the app: ~500MB RSS just for connection state — before your application logic
# Quick check: how many connections does your process currently hold?
ss -tp | grep :3000 | wc -l
# Check current FD usage
ls /proc/$(pgrep -n node)/fd | wc -l
# System-wide connection state
ss -s
Tune the kernel TCP stack for high connection counts:
# /etc/sysctl.d/99-sse.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
# Faster reuse of TIME_WAIT sockets (important for reconnecting clients)
net.ipv4.tcp_tw_reuse = 1
# Don't hold FIN_WAIT2 sockets too long
net.ipv4.tcp_fin_timeout = 15
Apply without reboot: sysctl -p /etc/sysctl.d/99-sse.conf
Docker Compose: A Working Starting Point
# docker-compose.yml
version: "3.9"
services:
app:
image: your-sse-app:latest
environment:
- PORT=3000
- NODE_ENV=production
ulimits:
nofile:
soft: 65535
hard: 65535
# Don't expose directly — let nginx handle it
expose:
- "3000"
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ulimits:
nofile:
soft: 65535
hard: 65535
depends_on:
- app
restart: unless-stopped
redis:
image: redis:7-alpine
# For shared event log / replay buffer
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
restart: unless-stopped
Monitoring What Actually Matters
Don’t just watch CPU and memory. For SSE workloads, the numbers that tell the real story:
- Active SSE connection count — instrument this in your app, expose as a metric
- Write queue depth per client — alert when p99 queue depth climbs
- Event latency — time from event emission to write() call; creeping latency means your publish loop is backing up
- Reconnect rate — spikes mean clients are getting disconnected (proxy timeout, OOM kills, deployments)
- FD usage —
cat /proc/<pid>/limitsvsls /proc/<pid>/fd | wc -l
A simple Prometheus metric in Node:
const activeConnections = new Gauge({
name: 'sse_active_connections',
help: 'Number of active SSE connections'
});
// Increment on connect, decrement on close
res.on('close', () => activeConnections.dec());
activeConnections.inc();
Production Checklist
Before you ship SSE to prod, run through this:
-
ulimit -nset for every process in the chain (app, nginx, systemd unit) -
proxy_buffering offandX-Accel-Buffering: noverified (check withcurl -N) -
proxy_read_timeoutset higher than your heartbeat interval - Heartbeat running on the server, interval < shortest proxy timeout
- Slow client detection: queue depth cap + either drop or disconnect
-
Last-Event-IDreplay implemented if ordering matters - Load balancing strategy decided (sticky vs shared log)
- Kernel TCP params tuned for your expected connection count
- Reconnect rate monitored and rate-limited at the proxy
# Verify buffering is actually off — you should see events arrive immediately
curl -N -H "Accept: text/event-stream" https://your-app.com/events
If events arrive in bursts instead of one by one, buffering is still on somewhere in the chain. Trace each proxy hop.
SSE scales fine. The protocol is solid. What doesn’t scale is treating it like a regular HTTP endpoint and ignoring the operational reality of tens of thousands of persistent connections. Get the fd limits right, kill the buffering, handle slow clients explicitly, and add heartbeats. Everything else is application logic.