>_
Published on

Server-Sent Events (SSE): Protocol Deep Dive

Written by Claude

Server-Sent Events (SSE): Protocol Deep Dive

Server-Sent Events (SSE) is a standardized server push technology that enables servers to push data to web clients over HTTP. Unlike WebSockets, SSE is unidirectional (server → client) and built on top of standard HTTP, making it simpler for many use cases.

Spec: WhatWG HTML Living Standard §9.2

Protocol Overview

SSE consists of two main components:

  1. EventSource API - Browser interface for receiving server push notifications as DOM events
  2. Event Stream Format (text/event-stream) - Wire format for delivering updates

Key Characteristics

  • Transport: Standard HTTP (or HTTP/2)
  • Direction: Unidirectional (server → client only)
  • Encoding: UTF-8 only (no alternative encodings supported)
  • Auto-reconnection: Built-in with configurable retry interval
  • Event tracking: Last event ID mechanism for resuming streams

Event Stream Format

The event stream is a UTF-8 encoded text stream where messages are separated by blank lines (\n\n).

Message Structure

Each message consists of one or more fields. Field format: field_name: field_value

Supported Fields:

FieldPurposeNotes
data:Message payloadMultiple data: lines concatenated with \n
event:Event type nameDefaults to "message" if omitted
id:Event identifierUsed for resuming streams via Last-Event-ID header
retry:Reconnection timeInteger in milliseconds
: (comment)Ignored lineUsed for keep-alive to prevent timeout

Line Separators

Valid line separators (per spec):

  • \r\n (CRLF)
  • \n (LF)
  • \r (CR)

Examples

Simple message:

data: Hello World

Multi-line data:

data: {
data:   "message": "Hello",
data:   "timestamp": 1697654321
data: }

Named event with ID:

event: user-joined
id: 42
data: {"user": "alice", "room": "lobby"}

Keep-alive comment:

: keep-alive

Custom retry interval:

retry: 5000

HTTP Requirements

Server Response

Required headers:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Status codes with special meaning:

  • 200 - Success, stream active
  • 204 No Content - Client should stop reconnecting
  • 301/307 - Redirect, client will follow and reconnect

Client Request

EventSource automatically includes when reconnecting:

GET /events HTTP/1.1
Last-Event-ID: 42

EventSource API

Client Usage

// Basic connection
const eventSource = new EventSource('/events')

// Listen to default "message" events
eventSource.onmessage = (event) => {
  console.log('Data:', event.data)
  console.log('ID:', event.lastEventId)
}

// Listen to custom event types
eventSource.addEventListener('user-joined', (event) => {
  const data = JSON.parse(event.data)
  console.log('User joined:', data.user)
})

// Connection opened
eventSource.onopen = () => {
  console.log('Connection established')
}

// Error handling
eventSource.onerror = (error) => {
  console.error('EventSource error:', error)
  // Auto-reconnects unless you close it
}

// Manually close (stops auto-reconnection)
eventSource.close()

ReadyState Values

EventSource.CONNECTING // 0 - Connection being established
EventSource.OPEN // 1 - Connection open, receiving events
EventSource.CLOSED // 2 - Connection closed, won't reconnect

Cross-Origin Requests

// CORS-enabled request with credentials
const eventSource = new EventSource('/events', {
  withCredentials: true,
})

Server must respond with appropriate CORS headers:

Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true

Server Implementation Examples

Node.js (Express)

app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')

  // Resume from last event ID
  const lastEventId = req.headers['last-event-id']

  // Send initial data
  res.write(`id: 1\n`)
  res.write(`data: Connection established\n\n`)

  // Send periodic updates
  const interval = setInterval(() => {
    res.write(`id: ${Date.now()}\n`)
    res.write(`event: update\n`)
    res.write(`data: ${JSON.stringify({ time: new Date() })}\n\n`)
  }, 1000)

  // Cleanup on client disconnect
  req.on('close', () => {
    clearInterval(interval)
    res.end()
  })
})

Python (Flask)

from flask import Flask, Response
import time
import json

app = Flask(__name__)

def event_stream():
    event_id = 0
    while True:
        event_id += 1
        data = json.dumps({'message': 'Hello', 'id': event_id})
        yield f"id: {event_id}\n"
        yield f"data: {data}\n\n"
        time.sleep(1)

@app.route('/events')
def events():
    return Response(
        event_stream(),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no'  # Disable nginx buffering
        }
    )

Go

func eventsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }

    // Get last event ID
    lastEventID := r.Header.Get("Last-Event-ID")

    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            fmt.Fprintf(w, "id: %d\n", time.Now().Unix())
            fmt.Fprintf(w, "data: {\"timestamp\": %d}\n\n", time.Now().Unix())
            flusher.Flush()
        case <-r.Context().Done():
            return
        }
    }
}

Reconnection Behavior

Automatic Reconnection

EventSource automatically reconnects when:

  • Connection drops unexpectedly
  • Network error occurs
  • Server closes connection (unless HTTP 204)

Default retry interval: ~3 seconds (browser-dependent)

Custom Retry Interval

Server can control retry timing:

retry: 10000

data: Retry in 10 seconds if disconnected

Resuming Streams

Using event IDs to resume:

Server tracks last sent ID:

const lastEventId = req.headers['last-event-id']
if (lastEventId) {
  // Send only events after this ID
  sendEventsSince(parseInt(lastEventId))
}

Client receives last ID automatically:

eventSource.onmessage = (event) => {
  console.log('Last ID:', event.lastEventId)
  // No manual tracking needed
}

SSE vs WebSockets

FeatureSSEWebSockets
DirectionUnidirectional (server → client)Bidirectional
ProtocolHTTP/HTTP2Custom protocol (ws://, wss://)
Data formatText only (UTF-8)Text or binary
Auto-reconnectBuilt-inManual implementation
Browser supportAll modern (not IE)All modern + IE 10+
Proxy/firewallWorks through HTTP proxiesMay be blocked
Message framingBuilt-in (newline-delimited)Built-in
OverheadHigher (HTTP headers per request)Lower (single upgrade)

Use SSE when:

  • Data flows primarily server → client (news feeds, notifications, live updates)
  • You need automatic reconnection and event tracking
  • You want to work with standard HTTP infrastructure
  • Text data is sufficient

Use WebSockets when:

  • You need bidirectional communication (chat, gaming, collaborative editing)
  • Low latency is critical
  • Binary data transfer is required

Practical Considerations

Buffering Issues

Problem: Reverse proxies (nginx, Apache) may buffer responses, delaying events.

Solutions:

Nginx:

location /events {
    proxy_pass http://backend;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
}

Apache:

<Location /events>
    ProxyPass http://backend/events
    ProxyPassReverse http://backend/events
    SetEnv proxy-nokeepalive 1
    SetEnv proxy-sendchunked 1
</Location>

Python (Flask/uWSGI):

# Response header to disable nginx buffering
headers={'X-Accel-Buffering': 'no'}

Connection Limits

Browser Limits: Browsers limit concurrent SSE connections per domain (typically 6).

Workaround: Use HTTP/2 (multiplexing) or share EventSource instances across tabs:

// Shared worker for cross-tab EventSource
const worker = new SharedWorker('event-worker.js')
worker.port.onmessage = (event) => {
  console.log('Event from shared worker:', event.data)
}

Keep-Alive

Problem: Connections may timeout on idle.

Solution: Send periodic comments:

def event_stream():
    while True:
        yield ': keep-alive\n\n'
        time.sleep(15)  # Every 15 seconds

        if new_data:
            yield f'data: {new_data}\n\n'

Memory Leaks

Always clean up EventSource instances:

// Bad - creates leak on navigation
const eventSource = new EventSource('/events')

// Good - cleanup on unmount
useEffect(() => {
  const eventSource = new EventSource('/events')

  return () => {
    eventSource.close()
  }
}, [])

Security Considerations

CORS

SSE respects CORS. Cross-origin requests require proper headers:

Access-Control-Allow-Origin: https://trusted-domain.com
Access-Control-Allow-Credentials: true

Authentication

Via URL (less secure):

const eventSource = new EventSource('/events?token=xyz')

Via cookies (preferred):

const eventSource = new EventSource('/events', {
  withCredentials: true,
})

Server validates session cookie in request.

DoS Protection

Rate limiting:

// Track connections per IP
const connections = new Map()

app.get('/events', (req, res) => {
  const ip = req.ip
  if (connections.get(ip) >= 5) {
    return res.status(429).send('Too many connections')
  }

  connections.set(ip, (connections.get(ip) || 0) + 1)

  req.on('close', () => {
    connections.set(ip, connections.get(ip) - 1)
  })
})

Input Validation

Never trust event IDs from clients:

const lastEventId = req.headers['last-event-id']
const validatedId = parseInt(lastEventId, 10)
if (isNaN(validatedId) || validatedId < 0) {
  // Start from beginning or reject
}

Gotchas

  1. UTF-8 only: No way to send binary data or other encodings
  2. IE not supported: Need fallback (polling, WebSockets)
  3. No request headers: Can't send custom headers (unlike WebSocket upgrade)
  4. 6 connection limit: Per domain on HTTP/1.1 (use HTTP/2)
  5. Line buffering: Some platforms buffer until \n, delaying events
  6. Reconnect on 200 only: Server must return 200 for active streams
  7. No backpressure: Server can't detect client processing speed

Browser Support

BrowserVersion
Chrome6+
Firefox6+
Safari5+
Opera11+
Edge79+
IENot supported

Polyfill available: Yaffle/EventSource

Summary

Server-Sent Events provide a standardized, HTTP-based server push mechanism with automatic reconnection and event tracking. While limited to unidirectional text data, SSE excels at scenarios like live feeds, notifications, and real-time dashboards where simplicity and HTTP compatibility matter.

Key advantages:

  • Built on standard HTTP (proxy-friendly)
  • Automatic reconnection with event tracking
  • Simple text-based protocol
  • Native browser support

When to reconsider:

  • Need bidirectional communication → WebSockets
  • Binary data required → WebSockets
  • IE support required → Fallback/polyfill needed

Spec reference: WhatWG HTML Living Standard - Server-sent events