Skip to content

SSE in Production — Complete Guide

DodaTech Updated 2026-06-28 10 min read

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

Can SSE handle 10000 concurrent connections?

Yes, with proper architecture. Use HTTP/2 for multiplexing, set connection limits per node, distribute via load balancer, and use event-driven servers like Node.js or FastAPI with async workers.

Does Heroku or Railway support SSE?

Heroku has a 30-second timeout for HTTP responses. SSE needs long-lived connections. Use a VPS, dedicated server, or a platform that supports streaming responses without timeout limits.

How do I handle SSE behind Cloudflare?

Cloudflare has a 100-second proxy timeout for free plans and 600 seconds for enterprise. Use Cloudflare Workers or a direct TCP connection for long-lived SSE streams.

Should I use sticky sessions for SSE with multiple nodes?

No. Use a pub/sub layer (Redis) so any node can serve any client. If a client reconnects and hits a different node, the new node has the full event stream.

How do I deploy SSE with zero downtime?

Use rolling deployments: start new nodes, drain old connections, redirect new connections to new nodes. Implement connection draining with a 30-second timeout before forced shutdown.

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