Siddhant Deval
Siddhant Deval
backend5 min read

Server-Sent Events (SSE): High-Throughput Unidirectional Streaming over HTTP in Express & React

SSE provides a vastly simpler, HTTP/2-multiplexed, firewall-resilient alternative to WebSockets for unidirectional server-to-client updates. This article dissects the text/event-stream wire protocol, explains why Nginx compression and buffering silently break event delivery, solves the native EventSource Bearer token dilemma via fetch() ReadableStream, and demonstrates how Last-Event-ID guarantees Monotonic Read Consistency across network disconnects.

Server-Sent Events (SSE): High-Throughput Unidirectional Streaming over HTTP in Express & React

Real-time is not a library you import, but an architectural contract across the network stack: choose the lowest-complexity transport that matches your data symmetry, budget for long-lived socket file descriptors, and decouple stateful client connections from stateless event producers via a distributed backplane. For the majority of real-time use cases — notifications, live feeds, AI token streaming, order status updates — data flows strictly from server to client. WebSockets' bidirectional full-duplex TCP machinery is unnecessary and costly.

In Part 1, we established that long polling solves delivery latency but re-opens a new HTTP connection after every response, exhausting the browser's HTTP/1.1 6-connection pool under multi-tab usage. Server-Sent Events (SSE) eliminates both problems: a single persistent HTTP response streams events indefinitely, with native browser auto-reconnection, built-in Last-Event-ID state reconciliation, and HTTP/2 multiplexing that carries dozens of independent streams over one TCP connection.

This article dissects SSE from the wire protocol up — the text/event-stream binary layout, why Nginx compression silently breaks it, how Last-Event-ID guarantees Monotonic Read Consistency across network drops, and why the native EventSource browser API cannot send Authorization headers (and the three production-grade workarounds).


1. The SSE Wire Protocol

1.1 What SSE Actually Is

Server-Sent Events is not a new protocol. It is a W3C standard that runs entirely over standard HTTP — no protocol upgrade, no handshake negotiation, no new TCP connection. The server sends a single HTTP 200 OK response with Content-Type: text/event-stream and then never closes it. The response body continues streaming event data indefinitely as a sequence of plaintext chunks separated by double newlines.

This is radically simpler than WebSockets. There is no Upgrade header, no 101 Switching Protocols, no SHA-1 challenge-response, no binary frame parser. SSE is just HTTP, extended in time.

1.2 The text/event-stream Frame Format

Each SSE event is a block of field-value lines terminated by a mandatory double newline \n\n:

HTTP
id: 42\n
event: fleet:position\n
data: {"vehicleId":"B12","lat":37.7749,"lng":-122.4194,"speed":67}\n
retry: 3000\n
\n
Field Required Purpose
id: <value> No Monotonic event identifier; browser stores as EventSource.lastEventId and re-sends as Last-Event-ID header on reconnect
event: <name> No Custom event type; default is message; client listens with source.addEventListener('fleet:position', ...)
data: <payload> Yes Event payload; may span multiple data: lines (concatenated with \n between them)
retry: <ms> No Instructs the browser to wait this many milliseconds before reconnecting after a disconnect
: comment No Lines starting with : are comments; used for heartbeat keep-alive (:\n\n) without triggering a client event

Multi-line data payloads:

HTTP
data: {"type":"position","vehicles":[
data:   {"id":"B12","lat":37.7749},
data:   {"id":"C04","lat":37.7800}
data: ]}\n
\n

The browser concatenates multi-line data: values with \n between each line before dispatching the event.

Crucial Requirement

The double newline \n\n (a blank line) is the mandatory event delimiter. A single \n merely separates fields within the same event. Missing the blank line between events causes the browser to accumulate data indefinitely, buffering an ever-growing event that is never dispatched.

1.3 Visualizing the Wire

The SSE wire protocol uses plaintext id, event, data, and retry fields with comment heartbeats; Last-Event-ID guarantees Monotonic Read Consistency across reconnections
The SSE wire protocol uses plaintext id, event, data, and retry fields with comment heartbeats; Last-Event-ID guarantees Monotonic Read Consistency across re…

2. Building a Production Express SSE Endpoint

2.1 The Naive Broken Pattern

TYPESCRIPT
// ❌ Broken SSE endpoint — will silently fail in production
app.get('/api/stream/fleet', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')

  setInterval(() => {
    res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`)
  }, 1000)

  // Missing: req.on('close') cleanup → interval leaks forever
  // Missing: Cache-Control: no-cache → proxies may cache the stream
  // Missing: X-Accel-Buffering: no → Nginx buffers chunks until threshold
  // Missing: res.flushHeaders() → headers not sent until first write
})

This will produce empty events on clients behind Nginx (Nginx buffers chunks until its 16KB threshold), leak an interval timer for every disconnected client (memory accumulation), and potentially serve a cached stream from a CDN.

2.2 The Correct Production SSE Endpoint

TYPESCRIPT
// server/lib/SseConnection.ts
import type { Request, Response } from 'express'

export interface SseEvent {
  id: number
  type: string
  data: unknown
}

export class SseConnection {
  private lastEventId: number
  private heartbeatTimer: ReturnType<typeof setInterval>

  constructor(
    private readonly req: Request,
    private readonly res: Response,
  ) {
    // Resume from Last-Event-ID header (reconnection reconciliation)
    this.lastEventId = parseInt(req.headers['last-event-id'] as string ?? '0', 10)

    this.initHeaders()
    this.startHeartbeat()
    this.req.on('close', () => this.teardown())
  }

  private initHeaders(): void {
    this.res.writeHead(200, {
      'Content-Type':    'text/event-stream',
      'Cache-Control':   'no-cache, no-transform',
      'Connection':      'keep-alive',
      // Critical: Disable Nginx response buffering for this stream
      'X-Accel-Buffering': 'no',
    })
    // Flush headers immediately — client starts listening before first event
    this.res.flushHeaders()
  }

  /** Send a comment line — resets proxy idle timeout, fires no client event */
  private startHeartbeat(): void {
    this.heartbeatTimer = setInterval(() => {
      if (!this.res.writableEnded) {
        this.res.write(': keep-alive\n\n')
      }
    }, 20_000) // Every 20s — well within Nginx proxy_read_timeout 30s
  }

  send(event: SseEvent): void {
    if (this.res.writableEnded) return

    const payload = [
      `id: ${event.id}`,
      `event: ${event.type}`,
      `data: ${JSON.stringify(event.data)}`,
      `retry: 3000`,
      '', // blank line = event delimiter
    ].join('\n') + '\n'

    this.res.write(payload)
  }

  get resumeFromId(): number {
    return this.lastEventId
  }

  private teardown(): void {
    clearInterval(this.heartbeatTimer)
    if (!this.res.writableEnded) {
      this.res.end()
    }
  }
}
TYPESCRIPT
// server/routes/stream.ts
import { SseConnection } from '../lib/SseConnection'
import { subscribeToTenantEvents, unsubscribe } from '../lib/EventBridge'
import { replayMissedEvents } from '../lib/EventStore'

app.get('/api/stream/fleet', requireAuth, async (req, res) => {
  const tenantId = req.user!.tenantId
  const conn = new SseConnection(req, res)

  // Replay any events missed during the disconnect window
  const missed = await replayMissedEvents(tenantId, conn.resumeFromId)
  for (const event of missed) {
    conn.send(event)
  }

  // Subscribe to live events for this tenant
  const subscriptionId = subscribeToTenantEvents(tenantId, (event) => {
    conn.send(event)
  })

  // Clean up when client disconnects (tab close, navigation, network drop)
  req.on('close', () => {
    unsubscribe(subscriptionId)
  })
})
Performance / Safety Warning

The Nginx Compression Trap: If your Nginx config has gzip on globally, it will attempt to compress text/event-stream responses. Compression requires buffering the response body to find compressible patterns — which means events are accumulated in Nginx's buffer (default 16KB) and only flushed in bulk, destroying real-time delivery. Always add gzip_types that excludes text/event-stream, or explicitly disable compression with add_header Content-Encoding identity on SSE routes.

2.3 Nginx Configuration for SSE

NGINX
# /etc/nginx/sites-available/fleettracker.conf

upstream fleet_api {
    server 127.0.0.1:3000;
    keepalive 64; # Reuse upstream connections
}

server {
    listen 443 ssl http2; # HTTP/2 is mandatory for multi-tab SSE

    location /api/stream/ {
        proxy_pass http://fleet_api;
        proxy_http_version 1.1;

        # WebSocket/SSE upgrade headers
        proxy_set_header Connection '';  # empty string for HTTP/2 upstream
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # Must exceed server heartbeat interval (20s) by a safe margin
        proxy_read_timeout 30s;

        # Disable Nginx response buffering for SSE streams
        proxy_buffering off;

        # Disable compression on streaming responses
        gzip off;

        # Signal to Express/upstream that buffering is disabled
        proxy_set_header X-Accel-Buffering no;
    }
}
HTTP/1.1 limits browsers to 6 concurrent connections per origin; HTTP/2 stream multiplexing carries all SSE streams and REST calls over one TCP connection with no contention
HTTP/1.1 limits browsers to 6 concurrent connections per origin; HTTP/2 stream multiplexing carries all SSE streams and REST calls over one TCP connection wi…

3. State Reconciliation: Last-Event-ID & Monotonic Read Consistency

3.1 How the Browser Tracks State

The browser's EventSource implementation is a state machine with built-in reconnection:

  1. Browser opens GET /api/stream/fleet and begins receiving events.
  2. Every event with an id: field updates the internal lastEventId property.
  3. On network disconnect, EventSource.readyState transitions to CONNECTING.
  4. After the retry: delay (default 3,000ms, overridable by server), the browser re-opens the connection — automatically including Last-Event-ID: <lastEventId> as a request header.
  5. The server reads req.headers['last-event-id'] and replays any events with a higher ID from its buffer.

This is Monotonic Read Consistency: the client is guaranteed to observe events in strictly increasing ID order and to never "go backwards" — even across reconnections.

3.2 The Event Buffer / Replay Store

The Last-Event-ID mechanism is only as strong as the server's ability to replay missed events. In-memory ring buffers work for small, single-node deployments:

TYPESCRIPT
// server/lib/EventStore.ts

interface StoredEvent {
  id: number
  tenantId: string
  type: string
  data: unknown
  createdAt: number
}

class InMemoryEventStore {
  private readonly BUFFER_SIZE = 1_000 // Last 1,000 events per tenant
  private readonly events = new Map<string, StoredEvent[]>()
  private idCounter = 0

  store(tenantId: string, type: string, data: unknown): StoredEvent {
    const event: StoredEvent = {
      id: ++this.idCounter,
      tenantId,
      type,
      data,
      createdAt: Date.now(),
    }

    if (!this.events.has(tenantId)) {
      this.events.set(tenantId, [])
    }

    const buffer = this.events.get(tenantId)!
    buffer.push(event)

    // Trim to BUFFER_SIZE — evict oldest events
    if (buffer.length > this.BUFFER_SIZE) {
      buffer.splice(0, buffer.length - this.BUFFER_SIZE)
    }

    return event
  }

  replay(tenantId: string, sinceId: number): StoredEvent[] {
    return (this.events.get(tenantId) ?? [])
      .filter((e) => e.id > sinceId)
  }
}

export const eventStore = new InMemoryEventStore()

export async function replayMissedEvents(
  tenantId: string,
  sinceId: number,
): Promise<StoredEvent[]> {
  if (sinceId === 0) return [] // First connection — no replay needed

  const buffered = eventStore.replay(tenantId, sinceId)

  if (buffered.length > 0) return buffered

  // In-memory buffer evicted (server restart / too many events) —
  // fall back to database for events missed in the last 5 minutes
  return db.events.find({
    tenantId,
    id: { $gt: sinceId },
    createdAt: { $gt: Date.now() - 5 * 60 * 1_000 },
  }).sort({ id: 1 })
}
Pro Tip & Optimization

For multi-node SSE deployments, the in-memory buffer only contains events from the specific node the client is connected to. Missed events from other nodes require a shared persistent store (Redis Streams, MongoDB oplog) as the replay source. This is explored in Part 5.

3.3 Dynamic retry: Tuning — Neutralizing Reconnection Storms

The retry: field lets the server control reconnect timing. On planned restarts, you can progressively increase the retry interval to stagger client reconnections:

TYPESCRIPT
// Before server shutdown: send high retry to spread reconnect load
export function sendShutdownSignal(connections: Set<SseConnection>): void {
  for (const conn of connections) {
    // Instruct clients to wait 10–40 seconds before reconnecting
    const jitter = Math.floor(Math.random() * 30_000)
    conn.sendRaw(`retry: ${10_000 + jitter}\n\n`)
    conn.sendRaw('event: server:shutdown\ndata: {}\n\n')
  }
}

4. The Authentication Dilemma: EventSource vs fetch() + ReadableStream

4.1 The Native EventSource API

The browser's native EventSource API is straightforward:

TYPESCRIPT
// Basic EventSource usage
const source = new EventSource('/api/stream/fleet', {
  withCredentials: true, // Sends httpOnly cookies cross-origin
})

source.addEventListener('fleet:position', (e: MessageEvent) => {
  const vehicle = JSON.parse(e.data) as VehiclePosition
  updateMap(vehicle)
})

source.addEventListener('error', (e) => {
  if (source.readyState === EventSource.CLOSED) {
    console.error('SSE connection permanently closed')
  }
  // If readyState === CONNECTING, browser is auto-reconnecting
})

// Cleanup
source.close()

The limitation: EventSource is a browser API with a fixed constructor signature. It supports withCredentials for httpOnly cookie-based auth, but cannot send custom request headers like Authorization: Bearer <token>.

4.2 Three Production Auth Patterns

Pattern Mechanism Tradeoffs
httpOnly Cookie Set Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict from your auth endpoint. Use withCredentials: true on EventSource. ✅ Simplest. ✅ XSS-resistant. ❌ Requires same-origin or CORS credentials: include. ❌ CSRF risk if SameSite not set correctly.
Short-Lived Ticket Token REST endpoint issues a single-use 30s cryptographically signed ticket. Client appends ?ticket=<token> to SSE URL. Server validates ticket on connection, discards after use. ✅ Works with any auth system. ✅ Token not reusable. ❌ Ticket visible in server access logs (mitigate with HTTPS).
fetch() + ReadableStream Use fetch() to open the SSE stream manually with Authorization header. Read the chunked response body via response.body.getReader(). ✅ Full header control. ✅ Works with Bearer tokens. ❌ Must implement reconnection, Last-Event-ID tracking, and event parsing manually.

4.3 Production useSSE React Hook

TYPESCRIPT
// hooks/useSSE.ts
import { useEffect, useRef, useCallback, useState } from 'react'

type SseEventMap = Record<string, (data: unknown) => void>

interface UseSseOptions {
  url: string
  events: SseEventMap
  enabled?: boolean
}

export function useSSE({ url, events, enabled = true }: UseSseOptions) {
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('closed')
  const sourceRef = useRef<EventSource | null>(null)
  const cleanupRef = useRef<(() => void) | null>(null)

  const connect = useCallback(() => {
    if (!enabled) return

    // Ticket-based auth: fetch a short-lived ticket from the REST API
    fetch('/api/auth/sse-ticket', { method: 'POST' })
      .then((r) => r.json())
      .then(({ ticket }: { ticket: string }) => {
        const source = new EventSource(`${url}?ticket=${ticket}`)
        sourceRef.current = source
        setStatus('connecting')

        source.onopen = () => setStatus('open')
        source.onerror = () => setStatus('connecting') // Auto-reconnects

        // Register all event listeners
        const handlers = Object.entries(events).map(([type, handler]) => {
          const listener = (e: MessageEvent) => handler(JSON.parse(e.data))
          source.addEventListener(type, listener)
          return { type, listener }
        })

        cleanupRef.current = () => {
          handlers.forEach(({ type, listener }) => {
            source.removeEventListener(type, listener)
          })
          source.close()
          setStatus('closed')
        }
      })
  }, [url, enabled, events])

  useEffect(() => {
    // React 18/19 StrictMode: double-invoke guard
    let cancelled = false

    if (!cancelled) connect()

    return () => {
      cancelled = true
      cleanupRef.current?.()
      cleanupRef.current = null
    }
  }, [connect])

  // Pause streaming when tab is hidden — saves bandwidth and server resources
  useEffect(() => {
    const handleVisibility = () => {
      if (document.visibilityState === 'hidden') {
        cleanupRef.current?.()
      } else {
        connect()
      }
    }

    document.addEventListener('visibilitychange', handleVisibility)
    return () => document.removeEventListener('visibilitychange', handleVisibility)
  }, [connect])

  return { status }
}
TYPESCRIPT
// Usage in a component
function FleetMap() {
  const { status } = useSSE({
    url: '/api/stream/fleet',
    events: {
      'fleet:position': (data) => updateVehiclePosition(data as VehiclePosition),
      'fleet:alert':    (data) => showAlert(data as FleetAlert),
      'server:shutdown': () => showReconnectingBanner(),
    },
  })

  return (
    <div>
      <ConnectionBadge status={status} />
      <MapView />
    </div>
  )
}
Performance / Safety Warning

React 18 StrictMode deliberately double-invokes useEffect in development to expose cleanup bugs. An SSE hook that opens EventSource without proper cleanup in the return function will create two simultaneous SSE connections — doubling server memory and potentially causing duplicate event delivery. Always close the EventSource in the cleanup return.


5. HTTP/1.1 vs HTTP/2 — The Multi-Tab SSE Bottleneck

5.1 The HTTP/1.1 6-Connection Limit, Revisited

In Part 1 we saw that long polling exhausts the browser's 6-connection pool. SSE has the same problem under HTTP/1.1 — but with a crucial difference: SSE connections are truly persistent (never re-opened between events), so each tab permanently occupies one connection slot for the entire session lifetime.

HTTP/2 eliminates the deadlock entirely. Each SSE stream is a separate HTTP/2 stream multiplexed over the single underlying TCP connection. Dozens of SSE connections across tabs share one socket with no contention.

Crucial Requirement

If you deploy SSE over HTTP/1.1 in a multi-tab application, you will receive bug reports from users saying "the app stops working when I open multiple tabs." The fix is not code — it is serving your application over HTTP/2. Verify with: curl -I --http2 https://your-domain.com/api/stream/fleet.

5.2 When to Choose SSE Over WebSockets

SSE excels where WebSockets add unnecessary complexity:

Use Case Best Transport Reason
AI LLM token streaming (ChatGPT-style) SSE Strict server-to-client; single persistent stream; simple HTTP
Live sports / finance score feeds SSE Unidirectional updates; HTTP/2 multiplexing; CDN-compatible
System notification toasts SSE Low-frequency push; no client-to-server channel needed
Order status tracking SSE State machine transitions; Last-Event-ID reconciliation
Live chat / collaborative editing WebSockets Bidirectional; low-latency user input; requires server ↔ client channel
Online gaming / cursor tracking WebSockets Sub-50ms bidirectional; binary frame efficiency
Vehicle telemetry command-response WebSockets Server sends position; client sends commands; truly bidirectional

6. CAP Theorem: Monotonic Read Consistency via Last-Event-ID

6.1 SSE Under Network Partitions

SSE is an AP-leaning transport with a crucial built-in consistency primitive: Last-Event-ID. When a network partition drops the client's SSE connection:

  1. Browser detects disconnect (readyState → CONNECTING).
  2. After retry: delay, browser re-connects with Last-Event-ID: <lastSeen>.
  3. Server replays events from its buffer with id > lastSeen.
  4. Client observes a causally consistent event stream — no events are skipped, no events are replayed out of order.

This is Monotonic Read Consistency from the PACELC model: the system may become temporarily unavailable (during the partition), but when it resumes, the client's view of the world only moves forward.

The consistency boundary: If events are generated faster than the server's in-memory buffer retains them during the disconnect, the Last-Event-ID replay falls back to the database. If even the database has rotated those events (TTL expiry, log compaction), the client must perform a full state sync — fetch the entire current application state and resume from the latest event ID.

TYPESCRIPT
// server: Handle reconnection with full-sync fallback
app.get('/api/stream/fleet', requireAuth, async (req, res) => {
  const tenantId = req.user!.tenantId
  const conn = new SseConnection(req, res)
  const sinceId = conn.resumeFromId

  const missed = await replayMissedEvents(tenantId, sinceId)

  if (sinceId > 0 && missed.length === 0) {
    // Buffer miss — force client to do a full state sync before resuming
    conn.sendRaw('event: sync:required\ndata: {}\n\n')
    // Client must call GET /api/state/full and re-render from scratch
  } else {
    for (const event of missed) conn.send(event)
  }

  // Continue streaming live events...
})
Mental Model Check

SSE provides Monotonic Read Consistency — a weaker but practical guarantee. The client's event stream always moves forward: new events are strictly newer than previously observed events. This is sufficient for most real-world UIs (dashboards, feeds, notifications). If you need Linearizability (every client sees the same globally ordered view at the same instant), you need a CP transport like Redis Streams with consumer group acknowledgments — covered in Part 5.


Summary

Concept Rule
SSE operates entirely over standard HTTP without a protocol upgrade dance — a single text/event-stream response handles continuous server-to-client push. Use Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no, and res.flushHeaders() on every SSE endpoint.
Nginx and compression middleware buffer SSE chunks until buffer thresholds are met; X-Accel-Buffering: no with disabled compression on event streams is mandatory for real-time delivery. Set proxy_buffering off and gzip off in Nginx for all /api/stream/ locations; set proxy_read_timeout to exceed the server heartbeat interval.
The Last-Event-ID protocol guarantees Monotonic Read Consistency: the server replays missed events from an in-memory buffer or database upon reconnect. Assign monotonically increasing id: to every event; implement replayMissedEvents(tenantId, sinceId) with database fallback when the in-memory buffer is evicted.
Under HTTP/1.1, opening multiple tabs with SSE connections exhausts the browser's 6-connection limit; HTTP/2 stream multiplexing eliminates this constraint entirely. Serve all SSE endpoints over HTTP/2 (listen 443 ssl http2 in Nginx); verify with curl --http2-prior-knowledge.
Native EventSource cannot send custom Authorization headers; production apps must use httpOnly cookies, cryptographic ticket query params, or fetch() with ReadableStream. Prefer httpOnly cookies with SameSite=Strict for SSE auth; use ticket tokens when cookie-based auth is not available.

What's Next

SSE solves unidirectional server-to-client streaming cleanly over HTTP. But for bidirectional communication — client sending commands to the server and receiving responses on the same low-latency channel — a persistent, full-duplex TCP connection is required. Part 3 dissects RFC 6455 WebSockets from the wire level up: the 101 Switching Protocols handshake, the 4-byte XOR masking key requirement, OS file descriptor and socket buffer constraints at 50,000 connections, and the precise architectural boundary between raw ws and the Engine.IO abstraction layer inside Socket.IO.

Part 3 → WebSockets Under the Hood: RFC 6455 Framing, the Upgrade Handshake & Raw ws vs socket.io — realtime-websockets-rfc6455-framing-socketio (coming in Part 3 of this series)

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#SSE#HTTP/2#Streaming#React#Express#Real-Time
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.