- Published on
Server-Sent Events (SSE): Protocol Deep Dive
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:
- EventSource API - Browser interface for receiving server push notifications as DOM events
- 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:
| Field | Purpose | Notes |
|---|---|---|
data: | Message payload | Multiple data: lines concatenated with \n |
event: | Event type name | Defaults to "message" if omitted |
id: | Event identifier | Used for resuming streams via Last-Event-ID header |
retry: | Reconnection time | Integer in milliseconds |
: (comment) | Ignored line | Used 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 active204 No Content- Client should stop reconnecting301/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
| Feature | SSE | WebSockets |
|---|---|---|
| Direction | Unidirectional (server → client) | Bidirectional |
| Protocol | HTTP/HTTP2 | Custom protocol (ws://, wss://) |
| Data format | Text only (UTF-8) | Text or binary |
| Auto-reconnect | Built-in | Manual implementation |
| Browser support | All modern (not IE) | All modern + IE 10+ |
| Proxy/firewall | Works through HTTP proxies | May be blocked |
| Message framing | Built-in (newline-delimited) | Built-in |
| Overhead | Higher (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
- UTF-8 only: No way to send binary data or other encodings
- IE not supported: Need fallback (polling, WebSockets)
- No request headers: Can't send custom headers (unlike WebSocket upgrade)
- 6 connection limit: Per domain on HTTP/1.1 (use HTTP/2)
- Line buffering: Some platforms buffer until
\n, delaying events - Reconnect on 200 only: Server must return 200 for active streams
- No backpressure: Server can't detect client processing speed
Browser Support
| Browser | Version |
|---|---|
| Chrome | 6+ |
| Firefox | 6+ |
| Safari | 5+ |
| Opera | 11+ |
| Edge | 79+ |
| IE | Not 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