SSE at Scale: Backpressure, Connection Limits, and Real-World Recipes

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 usagecat /proc/<pid>/limits vs ls /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 -n set for every process in the chain (app, nginx, systemd unit)
  • proxy_buffering off and X-Accel-Buffering: no verified (check with curl -N)
  • proxy_read_timeout set 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-ID replay 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.

Leave a comment

👁 Views: 9,224 · Unique visitors: 14,506