Server-Sent Events Architecture Guide

Real-time data delivery is a core requirement for modern web applications. Dashboards need live metrics, notifications must arrive instantly, and feeds should update without manual refreshes. Developers often reach for WebSockets by default, but Server-Sent Events offer a simpler, more robust solution for the majority of real-time use cases. SSE provides a unidirectional channel from server to client using plain HTTP, with built-in reconnection, event IDs for replay, and zero friction with existing infrastructure.

Despite being standardized in HTML5 and supported by every modern browser, SSE remains underused. Many teams overlook it because they conflate "real-time" with "WebSocket," missing that most real-time features only require server-to-client push. This guide covers SSE architecture from protocol fundamentals through production-scale deployment, showing when SSE is the right choice and how to build reliable streaming systems around it.

SSE vs WebSocket: Choosing the Right Protocol

The decision between SSE and WebSocket should be driven by data flow direction, not by a reflexive association between "real-time" and "WebSocket." SSE excels when the primary need is pushing data from server to client. WebSocket is necessary when the client must also send frequent, low-latency messages back to the server.

SSE operates over standard HTTP. The client opens a long-lived GET request, and the server keeps the response stream open, writing events as they occur. Because it is just HTTP, SSE works seamlessly with existing load balancers, proxies, CDNs, firewalls, and monitoring tools. There is no protocol upgrade, no special port, and no additional server infrastructure required.

WebSocket, by contrast, starts as an HTTP request but upgrades to a distinct binary protocol (RFC 6455). This upgrade breaks compatibility with some proxies and load balancers, requires special server configuration, and complicates monitoring and debugging. WebSocket connections cannot be cached, do not carry standard HTTP headers after the handshake, and need custom keep-alive mechanisms.

Here is a practical decision framework:

  • Use SSE for live dashboards, notification feeds, stock tickers, log streaming, build status updates, real-time search results, progress indicators, and any pattern where the server broadcasts and the client listens.
  • Use WebSocket for chat applications, multiplayer games, collaborative editing, remote terminals, and scenarios requiring bidirectional messaging with sub-100ms latency.
  • Use both when a page needs server push for most features but has one component requiring bidirectional communication.

SSE also has a practical advantage in error handling. The EventSource API automatically reconnects on failure, sends the last received event ID so the server can resume the stream, and exposes clean error events. WebSocket reconnection must be implemented entirely in application code. For applications built with HTMX and hypermedia patterns, SSE integrates naturally through the SSE extension, enabling server-pushed HTML fragment updates without any custom JavaScript.

The EventSource API and SSE Protocol

The SSE protocol is remarkably simple. The server sends a response with Content-Type: text/event-stream, and the body consists of UTF-8 text lines formatted with field names and values. The client uses the EventSource API to connect and listen for events.

An SSE stream consists of events separated by blank lines. Each event can have four fields: data (the payload), event (the event type), id (a unique identifier for reconnection), and retry (reconnection interval in milliseconds):

// Server response (text/event-stream)

id: 1
event: metric
data: {"cpu": 42.5, "memory": 71.2, "timestamp": 1696118400}

id: 2
event: metric
data: {"cpu": 38.1, "memory": 70.8, "timestamp": 1696118401}

id: 3
event: alert
data: {"level": "warning", "message": "CPU spike detected on node-7"}

: this is a comment line, used as a keep-alive heartbeat
retry: 3000

The client-side code is equally straightforward. The EventSource constructor takes a URL and optional configuration, and event listeners receive typed MessageEvent objects:

// Client-side EventSource usage
const source = new EventSource('/api/stream/metrics');

// Listen for the default 'message' event (no event field specified)
source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log('Default event:', data);
});

// Listen for named event types
source.addEventListener('metric', (e) => {
  const metric = JSON.parse(e.data);
  updateDashboard(metric);
});

source.addEventListener('alert', (e) => {
  const alert = JSON.parse(e.data);
  showNotification(alert.level, alert.message);
});

// Connection lifecycle events
source.addEventListener('open', () => {
  console.log('SSE connection established');
  hideReconnectionBanner();
});

source.addEventListener('error', (e) => {
  if (source.readyState === EventSource.CONNECTING) {
    console.log('Reconnecting...');
    showReconnectionBanner();
  } else if (source.readyState === EventSource.CLOSED) {
    console.log('Connection closed permanently');
  }
});

// Clean up when the page is unloaded
window.addEventListener('beforeunload', () => {
  source.close();
});

One important detail: the browser-native EventSource only supports GET requests and cannot send custom headers. If you need to pass authentication tokens, you have two options: include the token as a query parameter (less secure but simple), or use a polyfill like eventsource for Node.js or @microsoft/fetch-event-source for browsers that supports POST requests and custom headers.

Server-Side Implementation Patterns

The server side of SSE requires holding connections open and writing events to them as data becomes available. The implementation approach varies significantly by runtime. Event-driven servers like Node.js and Go handle this naturally, while thread-per-request servers need special configuration.

A Node.js implementation using Express demonstrates the core pattern:

// Express SSE endpoint
const express = require('express');
const app = express();

// Store active connections
const clients = new Map();

app.get('/api/stream/metrics', (req, res) => {
  // Set SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no'  // Disable Nginx buffering
  });

  // Send initial retry interval
  res.write('retry: 5000\n\n');

  // Handle reconnection — resume from last event
  const lastEventId = req.headers['last-event-id'];
  if (lastEventId) {
    const missedEvents = getEventsSince(parseInt(lastEventId));
    missedEvents.forEach(event => {
      res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
    });
  }

  // Register the client
  const clientId = crypto.randomUUID();
  clients.set(clientId, res);

  // Send heartbeat every 30 seconds to keep connection alive
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n');
  }, 30000);

  // Clean up on disconnect
  req.on('close', () => {
    clearInterval(heartbeat);
    clients.delete(clientId);
  });
});

// Broadcast an event to all connected clients
function broadcast(eventType, data, eventId) {
  const payload = `id: ${eventId}\nevent: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
  for (const [clientId, res] of clients) {
    res.write(payload);
  }
}

A Go implementation takes advantage of goroutines and channels for clean concurrency:

// Go SSE handler
func sseHandler(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("X-Accel-Buffering", "no")

    // Channel for receiving events
    events := make(chan Event, 64)
    broker.Subscribe(events)
    defer broker.Unsubscribe(events)

    // Handle reconnection
    lastID := r.Header.Get("Last-Event-ID")
    if lastID != "" {
        missed := broker.EventsSince(lastID)
        for _, e := range missed {
            fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", e.ID, e.Type, e.Data)
        }
        flusher.Flush()
    }

    ctx := r.Context()
    heartbeat := time.NewTicker(30 * time.Second)
    defer heartbeat.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-heartbeat.C:
            fmt.Fprintf(w, ": heartbeat\n\n")
            flusher.Flush()
        case event := <-events:
            fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n",
                event.ID, event.Type, event.Data)
            flusher.Flush()
        }
    }
}

Both implementations share key patterns: setting the correct headers (including X-Accel-Buffering: no for Nginx), handling reconnection via the Last-Event-ID header, sending periodic heartbeat comments to prevent connection timeouts, and cleaning up resources when the client disconnects.

Scaling SSE in Production

Scaling SSE presents challenges distinct from scaling traditional request-response HTTP. Each SSE connection is a long-lived TCP connection that consumes server resources for its entire duration. A server handling 50,000 concurrent SSE connections needs to manage those connections alongside regular request traffic.

The first scaling concern is the browser connection limit. HTTP/1.1 allows only six concurrent connections per domain. If a user opens multiple tabs, each with its own SSE connection, they can exhaust their connection pool. HTTP/2 multiplexes all connections over a single TCP connection, raising the practical limit to around 100 concurrent streams. Deploying over HTTP/2 is the simplest way to resolve this issue.

For horizontal scaling across multiple server instances, you need a pub/sub backbone. Each server instance subscribes to a shared message bus and broadcasts received messages to its local SSE clients. Redis Pub/Sub is the most common choice, though NATS, Apache Kafka, or PostgreSQL LISTEN/NOTIFY also work:

// Scaling SSE with Redis Pub/Sub
const Redis = require('ioredis');
const subscriber = new Redis();
const publisher = new Redis();

// Each server instance subscribes to the shared channel
subscriber.subscribe('sse:metrics', 'sse:alerts');

subscriber.on('message', (channel, message) => {
  const { eventType, data, eventId } = JSON.parse(message);
  // Broadcast to all local SSE clients
  broadcast(eventType, data, eventId);
});

// Publishing an event (from any server instance)
async function publishEvent(eventType, data) {
  const eventId = await getNextEventId();
  const channel = `sse:${eventType}`;

  // Store for reconnection replay
  await storeEvent(eventId, eventType, data);

  // Publish to all server instances
  await publisher.publish(channel, JSON.stringify({
    eventType,
    data,
    eventId
  }));
}

Event replay for reconnection is critical in a multi-server environment. When a client reconnects, it might hit a different server instance that does not have the missed events in memory. A shared event store (Redis Streams, a database table, or a message queue with retention) ensures any server instance can fulfill replay requests. Retention windows should match your expected maximum disconnection duration, typically 5 to 30 minutes for most applications.

Connection management at scale requires monitoring open connection counts per server, implementing graceful connection draining during deployments, and setting sensible limits on maximum concurrent connections. OpenTelemetry distributed tracing can help track event delivery latency across the pub/sub backbone and identify bottlenecks in the streaming pipeline.

Reconnection and Reliability Strategies

The built-in reconnection of the EventSource API handles basic disconnection scenarios, but production systems need additional reliability measures. Network interruptions, server restarts, load balancer timeouts, and proxy buffering can all disrupt SSE streams in ways that require careful handling.

Event IDs are the cornerstone of reliable delivery. Every event should carry a monotonically increasing ID. When the client reconnects, the browser automatically sends the Last-Event-ID header, and the server uses it to replay missed events. This creates an at-least-once delivery guarantee:

// Reliable event store with replay capability
class EventStore {
  private events: Map<number, StoredEvent> = new Map();
  private nextId: number = 1;
  private maxRetention: number = 10000; // Keep last 10,000 events

  store(eventType: string, data: unknown): number {
    const id = this.nextId++;
    this.events.set(id, {
      id,
      type: eventType,
      data: JSON.stringify(data),
      timestamp: Date.now()
    });

    // Evict old events
    if (this.events.size > this.maxRetention) {
      const oldest = this.events.keys().next().value;
      this.events.delete(oldest);
    }

    return id;
  }

  getEventsSince(lastId: number): StoredEvent[] {
    const events: StoredEvent[] = [];
    for (const [id, event] of this.events) {
      if (id > lastId) {
        events.push(event);
      }
    }
    return events;
  }

  // Check if we can serve a replay request
  canReplay(lastId: number): boolean {
    if (lastId === 0) return true;
    const oldestId = this.events.keys().next().value;
    return oldestId !== undefined && lastId >= oldestId - 1;
  }
}

When the server cannot replay events (the requested ID is older than the retention window), it should signal this to the client. One approach is to send a special "reset" event type that tells the client to perform a full data refresh rather than relying on incremental updates:

// Server-side replay handling with gap detection
function handleReconnection(req, res, eventStore) {
  const lastEventId = parseInt(req.headers['last-event-id'] || '0');

  if (lastEventId === 0) {
    // Fresh connection — send current state snapshot
    sendStateSnapshot(res);
    return;
  }

  if (!eventStore.canReplay(lastEventId)) {
    // Gap too large — tell client to reset
    res.write('event: reset\ndata: {"reason": "events_expired"}\n\n');
    sendStateSnapshot(res);
    return;
  }

  // Normal replay — send missed events
  const missed = eventStore.getEventsSince(lastEventId);
  for (const event of missed) {
    res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${event.data}\n\n`);
  }
}

Client-side, you should handle the "reset" event by discarding local state and rebuilding from the snapshot. You should also implement exponential backoff for repeated connection failures, even though EventSource handles basic reconnection. The native retry interval stays constant, which can overwhelm a struggling server with reconnection storms from thousands of clients.

Infrastructure Configuration

Deploying SSE in production requires specific configuration across the infrastructure stack. Reverse proxies, load balancers, and CDNs all have default behaviors that can interfere with long-lived streaming connections.

Nginx, the most common reverse proxy, buffers responses by default. This causes events to accumulate in the buffer instead of being delivered immediately. Disable buffering for SSE endpoints:

# Nginx configuration for SSE
location /api/stream/ {
    proxy_pass http://backend;

    # Disable response buffering
    proxy_buffering off;
    proxy_cache off;

    # SSE-specific headers
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;

    # Extended timeouts for long-lived connections
    proxy_read_timeout 86400s;  # 24 hours
    proxy_send_timeout 86400s;

    # Pass client IP for logging
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

# Rate limit SSE connections per client IP
limit_conn_zone $binary_remote_addr zone=sse_conn:10m;

location /api/stream/ {
    limit_conn sse_conn 10;  # Max 10 SSE connections per IP
    # ... other directives
}

Load balancers need sticky sessions or IP hash routing for SSE connections. If a client's connection is routed to a different backend on reconnection, the server will not have the client's subscription state. For environments using a pub/sub backbone, any server can handle any reconnection, making sticky sessions unnecessary.

HTTP/2 is strongly recommended for SSE deployments. It multiplexes all connections over a single TCP connection, eliminates the six-connection-per-domain limit, provides header compression that reduces the overhead of heartbeat comments, and supports stream prioritization so SSE traffic does not block other requests. Most reverse proxies handle HTTP/2 to the client while maintaining HTTP/1.1 to the backend, which works correctly with SSE.

Keep-alive heartbeats are essential. Without periodic data on the connection, intermediate proxies and firewalls will close idle connections. A comment line (: heartbeat) sent every 15 to 30 seconds keeps the connection alive without triggering event handlers on the client. The heartbeat interval should be shorter than the shortest timeout in your infrastructure chain.

Advanced Patterns and Real-World Applications

Beyond basic event broadcasting, SSE supports several advanced patterns that address common production requirements. Topic-based filtering lets clients subscribe to specific event categories without receiving the entire stream. Implement this with query parameters or path segments:

// Topic-based SSE subscription
// Client subscribes to specific topics
const source = new EventSource('/api/stream?topics=cpu,memory,disk');

// Server-side topic filtering
app.get('/api/stream', (req, res) => {
  const topics = new Set((req.query.topics || '').split(','));

  // Set up SSE headers...

  const clientId = crypto.randomUUID();
  clients.set(clientId, { res, topics });

  req.on('close', () => clients.delete(clientId));
});

function broadcastToTopic(topic, data, eventId) {
  for (const [id, client] of clients) {
    if (client.topics.has(topic)) {
      client.res.write(
        `id: ${eventId}\nevent: ${topic}\ndata: ${JSON.stringify(data)}\n\n`
      );
    }
  }
}

Backpressure handling becomes important when the server generates events faster than the client can consume them. Unlike WebSocket, SSE does not have a built-in flow control mechanism. If the server writes faster than the TCP send buffer can drain, the write call will block (in synchronous runtimes) or the buffer will grow without bound (in asynchronous runtimes). Implement server-side buffering with overflow protection:

// Backpressure-aware event delivery
class SSEClient {
  private buffer: string[] = [];
  private maxBufferSize: number = 1000;
  private draining: boolean = false;

  send(event: string): boolean {
    if (this.buffer.length >= this.maxBufferSize) {
      // Drop oldest events or disconnect the slow client
      this.buffer.shift();
      console.warn(`Buffer overflow for client ${this.id}, dropping events`);
    }

    this.buffer.push(event);
    this.drain();
    return this.buffer.length < this.maxBufferSize;
  }

  private drain(): void {
    if (this.draining) return;
    this.draining = true;

    while (this.buffer.length > 0) {
      const event = this.buffer[0];
      const canWrite = this.res.write(event);
      this.buffer.shift();

      if (!canWrite) {
        // TCP buffer full — wait for drain event
        this.res.once('drain', () => this.drain());
        this.draining = false;
        return;
      }
    }

    this.draining = false;
  }
}

For applications that integrate SSE with local-first architecture, the SSE stream serves as the live update channel while the local database handles persistence and offline access. Changes arrive over SSE and are applied to the local CRDT document, merging seamlessly with any local edits made while offline. This combines the simplicity of SSE with the resilience of local-first data storage.

Monitoring SSE in production requires tracking metrics that standard HTTP monitoring overlooks: connection duration distribution, reconnection frequency per client, event delivery latency (time from publish to client receipt), buffer utilization across server instances, and events dropped due to backpressure. Exporting these metrics to your observability stack provides visibility into the health of your streaming infrastructure and helps you identify capacity issues before they affect users.

Server-Sent Events deserve a prominent place in every backend engineer's toolkit. For the vast majority of real-time features, SSE delivers the functionality of WebSocket with a fraction of the complexity. It leverages existing HTTP infrastructure, provides automatic reconnection and replay, and integrates cleanly with every framework and language. Start with SSE as your default for server-to-client push, and reach for WebSocket only when you genuinely need bidirectional communication.