SSE in Production — Complete Guide
In this tutorial, you will learn about SSE in Production. We cover key concepts, practical examples, and best practices to help you master this topic.
Deploy Server-Sent Events in production: handle reverse proxies, load balancing, scaling, connection limits, monitoring, rate limiting, and production hardening for real-world SSE services.
What You Learn
You will learn how to deploy SSE services in production environments, handle reverse proxies like nginx and HAProxy, scale across multiple nodes, monitor connection health, implement rate limiting, and harden your SSE infrastructure.
Why It Matters
SSE in development works fine with a single server. In production you face reverse proxy buffers, load balancer timeouts, connection limits, process crashes, and client storms. Without production hardening your SSE service breaks under real-world conditions.
Real-World Use
DodaTech's live monitoring dashboard serves 5000+ concurrent SSE connections across 4 nodes behind an nginx load balancer. Each node handles 1500 connections with automatic failover, connection draining, and health check endpoints.
Reverse Proxy Configuration
nginx
upstream sse_backend {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
server {
listen 80;
server_name sse.example.com;
location /events {
proxy_pass http://sse_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}
}
Expected behavior: nginx streams SSE events without buffering, passes connections to the least-loaded backend, and keeps connections open for 24 hours.
HAProxy
global
maxconn 10000
defaults
timeout connect 5s
timeout client 24h
timeout server 24h
frontend sse_front
bind *:80
default_backend sse_back
backend sse_back
balance leastconn
option http-server-close
server sse1 127.0.0.1:3001 maxconn 2000
server sse2 127.0.0.1:3002 maxconn 2000
server sse3 127.0.0.1:3003 maxconn 2000
Expected behavior: HAProxy distributes SSE connections using leastconn, sets 24-hour timeouts, and limits each backend to 2000 concurrent connections.
Connection Management
class ProductionSSEManager {
constructor(options = {}) {
this.maxConnections = options.maxConnections || 2000;
this.connections = new Map();
this.connectionCounter = 0;
this.stats = {
totalConnections: 0,
rejectedConnections: 0,
peakConnections: 0,
};
}
canAcceptConnection() {
if (this.connections.size >= this.maxConnections) {
this.stats.rejectedConnections++;
return false;
}
return true;
}
addConnection(res, metadata = {}) {
if (!this.canAcceptConnection()) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Server at capacity' }));
return null;
}
this.connectionCounter++;
const id = `conn_${Date.now()}_${this.connectionCounter}`;
const conn = {
id,
res,
connectedAt: Date.now(),
lastActivity: Date.now(),
metadata,
eventCount: 0,
};
this.connections.set(id, conn);
this.stats.totalConnections++;
this.stats.peakConnections = Math.max(
this.stats.peakConnections,
this.connections.size
);
res.on('close', () => {
this.connections.delete(id);
console.log(`Connection ${id} closed (${this.connections.size} remaining)`);
});
return id;
}
sendEvent(connectionId, event, data) {
const conn = this.connections.get(connectionId);
if (!conn || conn.res.destroyed) {
this.connections.delete(connectionId);
return false;
}
try {
conn.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
conn.lastActivity = Date.now();
conn.eventCount++;
return true;
} catch (err) {
this.connections.delete(connectionId);
return false;
}
}
broadcast(event, data) {
let sent = 0;
for (const [id, conn] of this.connections) {
try {
conn.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
conn.lastActivity = Date.now();
conn.eventCount++;
sent++;
} catch (err) {
this.connections.delete(id);
}
}
return sent;
}
getStats() {
return {
...this.stats,
currentConnections: this.connections.size,
};
}
healthCheck() {
const now = Date.now();
const staleTimeout = 300000; // 5 minutes
for (const [id, conn] of this.connections) {
if (conn.res.destroyed) {
this.connections.delete(id);
continue;
}
// Remove stale connections with no activity
if (now - conn.lastActivity > staleTimeout) {
try {
conn.res.end();
} catch (e) { }
this.connections.delete(id);
}
}
return {
activeConnections: this.connections.size,
cleanedStale: this.stats.totalConnections - this.connections.size,
};
}
}
Expected output: Manager rejects connections when at capacity, tracks per-connection statistics, cleans stale connections, and returns accurate health data.
Scaling Across Nodes
const redis = require('redis');
const http = require('http');
class RedisSSEBridge {
constructor(redisUrl) {
this.publisher = redis.createClient({ url: redisUrl });
this.subscriber = redis.createClient({ url: redisUrl });
this.clients = new Map();
this.channel = 'sse:events';
this.subscriber.subscribe(this.channel, (message) => {
const { event, data } = JSON.parse(message);
this.broadcastLocal(event, data);
});
}
handleConnection(req, res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const id = Date.now().toString();
this.clients.set(id, res);
req.on('close', () => {
this.clients.delete(id);
});
}
publish(event, data) {
// Publish to Redis - all nodes receive it
this.publisher.publish(this.channel, JSON.stringify({ event, data }));
}
broadcastLocal(event, data) {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const [id, client] of this.clients) {
try {
client.write(message);
} catch (e) {
this.clients.delete(id);
}
}
}
}
Expected behavior: When one node publishes an event, Redis pub/sub broadcasts it to all other nodes, and each node forwards it to its local SSE clients. This enables horizontal scaling.
Monitoring and Health Endpoints
const http = require('http');
class SSEMonitor {
constructor() {
this.metrics = {
connectionsOpened: 0,
connectionsClosed: 0,
eventsSent: 0,
bytesSent: 0,
errors: 0,
startTime: Date.now(),
};
this.activeConnections = new Map();
}
trackOpen(id, res) {
this.metrics.connectionsOpened++;
this.activeConnections.set(id, {
res,
opened: Date.now(),
eventsReceived: 0,
});
}
trackEvent(id, dataSize) {
this.metrics.eventsSent++;
this.metrics.bytesSent += dataSize;
const conn = this.activeConnections.get(id);
if (conn) conn.eventsReceived++;
}
trackClose(id) {
this.metrics.connectionsClosed++;
this.activeConnections.delete(id);
}
trackError() {
this.metrics.errors++;
}
getHealth() {
const uptime = Date.now() - this.metrics.startTime;
return {
status: 'healthy',
uptime: Math.floor(uptime / 1000),
activeConnections: this.activeConnections.size,
totalConnections: this.metrics.connectionsOpened,
eventsSent: this.metrics.eventsSent,
bytesSent: this.metrics.bytesSent,
errorRate: this.metrics.errors / Math.max(this.metrics.connectionsOpened, 1),
memoryUsage: process.memoryUsage(),
};
}
getConnectionsDetail() {
return Array.from(this.activeConnections.entries()).map(([id, conn]) => ({
id,
age: Date.now() - conn.opened,
eventsReceived: conn.eventsReceived,
}));
}
}
const monitor = new SSEMonitor();
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(monitor.getHealth()));
} else if (req.url === '/connections') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(monitor.getConnectionsDetail()));
} else if (req.url === '/metrics') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
const m = monitor.metrics;
res.end([
`# HELP sse_connections_active Active SSE connections`,
`# TYPE sse_connections_active gauge`,
`sse_connections_active ${monitor.activeConnections.size}`,
`# HELP sse_events_total Total events sent`,
`# TYPE sse_events_total counter`,
`sse_events_total ${m.eventsSent}`,
`# HELP sse_bytes_total Total bytes sent`,
`# TYPE sse_bytes_total counter`,
`sse_bytes_total ${m.bytesSent}`,
`# HELP sse_errors_total Total errors`,
`# TYPE sse_errors_total counter`,
`sse_errors_total ${m.errors}`,
].join('\n'));
}
});
Expected output: Health endpoint returns JSON with uptime, connection counts, event rates, and memory usage. Metrics endpoint returns Prometheus-formatted text.
Rate Limiting
class SSERateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || 60000;
this.maxEvents = options.maxEvents || 100;
this.clientWindows = new Map();
}
checkRateLimit(clientId) {
const now = Date.now();
let window = this.clientWindows.get(clientId);
if (!window || now - window.start > this.windowMs) {
window = { start: now, count: 0 };
this.clientWindows.set(clientId, window);
}
window.count++;
if (window.count > this.maxEvents) {
return {
allowed: false,
retryAfter: Math.ceil((this.windowMs - (now - window.start)) / 1000),
};
}
return { allowed: true, remaining: this.maxEvents - window.count };
}
getRateLimitHeaders(clientId) {
const result = this.checkRateLimit(clientId);
return {
'X-RateLimit-Limit': this.maxEvents.toString(),
'X-RateLimit-Remaining': result.allowed
? result.remaining.toString()
: '0',
'X-RateLimit-Reset': result.allowed
? '0'
: result.retryAfter.toString(),
};
}
}
Expected output: Rate limiter allows 100 events per minute per client and returns standard rate limit headers. Excess events are rejected with 429 status.
Common Mistakes
1. Not Disabling Proxy Buffering
Proxies buffer response data by default. SSE needs streaming. Always set proxy_buffering off for nginx and similar options for other proxies.
2. Incorrect Timeout Values
Default proxy timeouts are 30-60 seconds. SSE connections last hours. Set read/send timeouts to 24h or disable them entirely for SSE routes.
3. No Connection Draining on Deploy
When deploying new code, existing SSE connections point to the old process. Implement graceful shutdown: stop accepting new connections, notify clients to reconnect, wait for drain timeout.
4. Missing Health Check Endpoints
Without health checks you cannot distinguish between no clients and a dead server. Add /health and /metrics endpoints. Configure load balancer health checks against them.
5. No Connection Limit Per Node
Without limits, a traffic spike creates millions of connections and exhausts file descriptors. Set hard limits per node and reject excess connections with 503.
Practice Questions
1. Why must proxy buffering be disabled for SSE?
Reverse proxies buffer responses by default. SSE data must arrive immediately. Buffering delays events and breaks the real-time nature of SSE.
2. How do you scale SSE across multiple nodes?
Use Redis pub/sub or a Message Broker. Each node subscribes to a shared channel. When one node publishes an event, all nodes receive it and forward to their local clients.
3. What is connection draining and why is it important?
Connection draining stops accepting new connections and waits for existing ones to finish before shutdown. Without it, active SSE clients lose their connection during deployments.
4. How do you monitor SSE connection health?
Track active connections, events sent, bytes transferred, and error rates. Expose health endpoints. Set up Prometheus metrics. Alert on connection count drops or error spikes.
Challenge
Build a production SSE deployment with: nginx reverse proxy config with buffering disabled, 3-node Node.js backend with Redis pub/sub scaling, connection manager with 2000 max per node, health check at /health, Prometheus metrics at /metrics, rate limiter (100 events/min per client), and graceful shutdown with 30-second drain timeout.
FAQ
Mini Project: Production SSE Deployment
const http = require('http');
const { createClient } = require('redis');
class ProductionSSEServer {
constructor(port, redisUrl) {
this.port = port;
this.clients = new Map();
this.clientId = 0;
this.shuttingDown = false;
this.redisPub = createClient({ url: redisUrl });
this.redisSub = createClient({ url: redisUrl });
this.setupRedis();
this.startServer();
}
async setupRedis() {
await this.redisPub.connect();
await this.redisSub.connect();
await this.redisSub.subscribe('sse:events', (message) => {
const { event, data } = JSON.parse(message);
this.broadcastLocal(event, data);
});
}
startServer() {
const server = http.createServer((req, res) => {
if (req.url === '/events') {
this.handleConnection(req, res);
} else if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(this.getHealth()));
} else if (req.url === '/publish' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const { event, data } = JSON.parse(body);
this.redisPub.publish('sse:events', JSON.stringify({ event, data }));
res.writeHead(200);
res.end(JSON.stringify({ published: true }));
});
} else {
res.writeHead(404);
res.end();
}
});
// Graceful shutdown
process.on('SIGTERM', () => this.gracefulShutdown(server));
process.on('SIGINT', () => this.gracefulShutdown(server));
server.listen(this.port, () => {
console.log(`Production SSE on port ${this.port}`);
});
}
handleConnection(req, res) {
if (this.shuttingDown) {
res.writeHead(503);
res.end('Server shutting down');
return;
}
this.clientId++;
const id = this.clientId;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
this.clients.set(id, res);
req.on('close', () => {
this.clients.delete(id);
});
}
broadcastLocal(event, data) {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const [id, client] of this.clients) {
try {
client.write(message);
} catch (e) {
this.clients.delete(id);
}
}
}
getHealth() {
return {
status: this.shuttingDown ? 'shutting_down' : 'healthy',
connections: this.clients.size,
uptime: process.uptime(),
};
}
async gracefulShutdown(server) {
this.shuttingDown = true;
console.log('Shutting down, draining connections...');
// Notify clients to reconnect
this.broadcastLocal('shutdown', {
message: 'Server restarting, reconnect in 5 seconds',
reconnectDelay: 5000,
});
// Wait for drain
await new Promise(r => setTimeout(r, 30000));
// Force close remaining connections
for (const [id, client] of this.clients) {
try { client.end(); } catch (e) { }
}
server.close(() => {
console.log('Server shut down');
process.exit(0);
});
}
}
new ProductionSSEServer(3001, 'redis://localhost:6379');
Expected output: Server handles connections, publishes events via Redis, broadcasts across nodes, exposes health endpoint, and gracefully drains connections on shutdown.
What's Next
Now that you understand production SSE deployment, build the Mini Project: Live Dashboard to apply everything you've learned.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro