Skip to content
SP StackPractices
intermediate By Mathias Paulenko

Server-Sent Events with Node.js and Express

Implement real-time server-to-client push using Server-Sent Events in Node.js with Express, covering connection management, event types, reconnection logic, and backpressure handling

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Server-Sent Events with Node.js and Express

Server-Sent Events (SSE) provide a lightweight, unidirectional channel for pushing real-time updates from server to browser over HTTP. Unlike WebSockets, SSE uses standard HTTP, auto-reconnects, and works seamlessly with existing infrastructure like load balancers. This recipe covers Express implementation, event types, connection management, and graceful client reconnection.

When to Use This

  • Live dashboards, activity feeds, or notification streams need server-initiated updates
  • You want real-time push without the complexity of bidirectional WebSockets
  • Existing HTTP infrastructure (caching, auth, LB) must be reused

Solution

1. Express SSE Endpoint

// sse/SSEEndpoint.ts
import express, { Request, Response } from 'express';

interface Client {
  id: string;
  response: Response;
  lastEventId: string | null;
}

class SSEManager {
  private clients = new Map<string, Client>();

  addClient(res: Response): string {
    const id = crypto.randomUUID();

    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no', // Disable nginx buffering
    });

    // Send initial connection event
    res.write(`event: connected\nid: ${id}\ndata: ${JSON.stringify({ clientId: id })}\n\n`);

    this.clients.set(id, { id, response: res, lastEventId: null });

    res.on('close', () => this.removeClient(id));
    return id;
  }

  removeClient(id: string): void {
    this.clients.delete(id);
  }

  broadcast(event: string, data: unknown): void {
    const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
    this.clients.forEach((client) => {
      client.response.write(payload);
    });
  }

  sendTo(clientId: string, event: string, data: unknown): boolean {
    const client = this.clients.get(clientId);
    if (!client) return false;

    client.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
    return true;
  }
}

const sseManager = new SSEManager();

// Route
app.get('/events', (req: Request, res: Response) => {
  const lastEventId = req.headers['last-event-id'] as string | undefined;
  const clientId = sseManager.addClient(res);

  if (lastEventId) {
    // Client reconnected; replay missed events from that ID
    replayEvents(clientId, lastEventId);
  }
});

2. Typed Event Protocol

// sse/EventProtocol.ts
type SSEEvent =
  | { type: 'user:joined'; data: { userId: string; name: string } }
  | { type: 'user:left'; data: { userId: string } }
  | { type: 'notification'; data: { message: string; severity: 'info' | 'warning' | 'error' } }
  | { type: 'heartbeat'; data: { timestamp: number } };

function sendEvent(clientId: string, event: SSEEvent): void {
  sseManager.sendTo(clientId, event.type, event.data);
}

// Heartbeat to keep connections alive
setInterval(() => {
  sseManager.broadcast('heartbeat', { timestamp: Date.now() });
}, 30000);

3. Client-Side Connection

// client/SSEClient.ts
class SSEClient {
  private eventSource: EventSource | null = null;
  private reconnectDelay = 1000;
  private maxReconnectDelay = 30000;

  connect(url: string): void {
    this.eventSource = new EventSource(url);

    this.eventSource.onopen = () => {
      this.reconnectDelay = 1000; // Reset backoff
    };

    this.eventSource.addEventListener('user:joined', (e) => {
      const data = JSON.parse(e.data);
      console.log('User joined:', data.name);
    });

    this.eventSource.addEventListener('notification', (e) => {
      const data = JSON.parse(e.data);
      showToast(data.message, data.severity);
    });

    this.eventSource.onerror = () => {
      this.eventSource?.close();
      this.scheduleReconnect(url);
    };
  }

  private scheduleReconnect(url: string): void {
    setTimeout(() => {
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
      this.connect(url);
    }, this.reconnectDelay);
  }

  disconnect(): void {
    this.eventSource?.close();
  }
}

4. Backpressure and Error Handling

// sse/BackpressureHandler.ts
class SafeSSEManager extends SSEManager {
  broadcastSafe(event: string, data: unknown): void {
    const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
    const deadClients: string[] = [];

    this.clients.forEach((client) => {
      const writable = client.response.writable;
      if (!writable) {
        deadClients.push(client.id);
        return;
      }

      const flushed = client.response.write(payload);
      if (!flushed) {
        // Buffer full; pause this client
        client.response.once('drain', () => {
          // Resume when buffer clears
        });
      }
    });

    deadClients.forEach((id) => this.removeClient(id));
  }
}

How It Works

  • Event stream is a persistent HTTP response with Content-Type: text/event-stream
  • EventSource API in browsers auto-reconnects and parses event:, data:, and id: fields
  • Heartbeat messages prevent proxy timeouts and detect stale connections
  • Last-Event-ID header enables replay of missed events after reconnection

Production Considerations

  • Disable response buffering in reverse proxies (nginx, HAProxy) for immediate delivery
  • Set appropriate timeout values on load balancers for long-lived connections
  • Monitor connection count to prevent memory exhaustion under high load

Common Mistakes

  • Not sending heartbeats, causing silent disconnections behind proxies
  • Broadcasting large payloads to all clients without backpressure handling
  • Storing all events in memory for replay instead of using a bounded buffer or persistent log

FAQ

Q: SSE vs WebSockets: which to choose? A: Use SSE for server-to-client push over HTTP. Use WebSockets when you need true bidirectional communication or binary data.

Q: How many concurrent SSE connections can a Node.js server handle? A: Thousands per process, limited by memory and OS file descriptors. Use clustering or worker threads for horizontal scaling.