WebSockets vs Server-Sent Events (SSE): Choosing Your Real-Time Protocol
Quick Summary
Server-Sent Events (SSE) provides a simple, HTTP-based protocol for server-to-client streaming, while WebSockets offers full bidirectional communication. Choose SSE when you only need server push with automatic reconnection and HTTP/2 compatibility. Choose WebSockets for chat, gaming, or any scenario requiring client-to-server communication beyond the initial request.
Table of Contents
- At a Glance Comparison
- How Server-Sent Events Work
- How WebSockets Work
- Key Differences
- Use Case Analysis
- Implementation Examples
- Browser Quirks and Limitations
- Conclusion
- Further Reading
At a Glance Comparison
Feature | Server-Sent Events | WebSockets |
---|---|---|
Direction | Server β Client only | Bidirectional |
Protocol | HTTP/1.1 or HTTP/2 | WebSocket (after HTTP upgrade) |
Automatic Reconnection | β Built-in | β Manual implementation |
Connection Limit | 6 per domain (HTTP/1.1) | No browser limit |
Binary Data | β Text only | β Binary and text |
Compression | β HTTP compression | β Permessage-deflate |
HTTP/2 Multiplexing | β Full support | β No benefit |
Proxy/CDN Support | β Excellent | β Good (modern CDNs) |
CORS Support | β Standard | β οΈ Origin check only |
Browser Support | 97% | 99%+ |
Message Framing | β Built-in with IDs | β Manual implementation |
Complexity | Low | Medium |
How Server-Sent Events Work
SSE uses a persistent HTTP connection to stream events from server to client using a simple text-based format.
The EventSource API
const eventSource = new EventSource('/events');
eventSource.onopen = (event) => { console.log('Connection opened');};
eventSource.onmessage = (event) => { console.log('Received:', event.data);};
eventSource.onerror = (event) => { if (event.target.readyState === EventSource.CLOSED) { console.log('Connection closed'); } else { console.log('Connection error, will retry'); }};
// Named eventseventSource.addEventListener('user-login', (event) => { console.log('User logged in:', event.data);});
SSE Wire Format
The SSE protocol uses a simple text format:
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
data: First message
data: Second messageid: 2
event: user-logindata: {"username": "alice"}
: This is a comment (heartbeat)
data: Multi-line messagedata: Line 2data: Line 3
retry: 5000
Each message is separated by double newlines (\n\n
), with fields including:
data:
- The message payloadevent:
- Event type for named eventsid:
- Message ID for resumptionretry:
- Reconnection time in milliseconds:
- Comments (often used for keepalive)
Connection Lifecycle
Client Server | | |-------- GET /events --------->| | (Includes Last-Event-ID if | | reconnecting) | | | |<-- HTTP 200 text/event-stream -| | | | | |<---- data: message 1\n\n ------| | | |<---- data: message 2\n\n ------| | | |<------ : keepalive\n\n -------| | | |\ /| | \ Connection drops / | | \ / | | | |-- GET /events (Last-Event-ID) >| | (Automatic reconnection) | | | |<--- Resume from message 3 -----| | |
How WebSockets Work
WebSockets create a full-duplex communication channel through an HTTP upgrade handshake.
WebSocket Connection
const ws = new WebSocket('wss://example.com/socket');
ws.onopen = (event) => { console.log('Connected'); ws.send('Hello Server');};
ws.onmessage = (event) => { console.log('Received:', event.data);};
ws.onerror = (error) => { console.error('Error:', error);};
ws.onclose = (event) => { console.log('Disconnected:', event.code, event.reason); // Manual reconnection needed};
// Send various data typesws.send('Text message');ws.send(JSON.stringify({ type: 'json' }));ws.send(new Blob(['binary data']));ws.send(new ArrayBuffer(8));
Key Differences
1. Communication Direction
SSE: Unidirectional (Server β Client)
// SSE: Client can only receiveeventSource.onmessage = (event) => { updateUI(event.data);};
// To send data, need separate HTTP requestasync function sendToServer(data) { await fetch('/api/action', { method: 'POST', body: JSON.stringify(data) });}
WebSocket: Bidirectional
// WebSocket: Both send and receive on same connectionws.onmessage = (event) => { updateUI(event.data);};
ws.send(JSON.stringify({ action: 'user-input', data: 'Hello'}));
2. Automatic Reconnection
SSE: Built-in reconnection with resume
// No code needed! EventSource handles it automaticallyconst eventSource = new EventSource('/events');
// Server can set retry interval// retry: 5000
WebSocket: Manual reconnection required
class ReconnectingWebSocket { constructor(url) { this.url = url; this.reconnectDelay = 1000; this.shouldReconnect = true; this.connect(); }
connect() { this.ws = new WebSocket(this.url);
this.ws.onclose = () => { if (this.shouldReconnect) { setTimeout(() => { this.reconnectDelay *= 2; // Exponential backoff this.connect(); }, this.reconnectDelay); } };
this.ws.onopen = () => { this.reconnectDelay = 1000; // Reset delay }; }}
3. Data Types
SSE: Text only (UTF-8)
// SSE: Must serialize binary dataeventSource.onmessage = (event) => { // event.data is always a string const text = event.data;
// For binary, need base64 encoding const binary = atob(event.data);};
WebSocket: Binary and text
// WebSocket: Native binary supportws.binaryType = 'arraybuffer'; // or 'blob'
ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { // Binary data const view = new DataView(event.data); processBytes(view); } else { // Text data const text = event.data; }};
// Send binary directlyconst buffer = new ArrayBuffer(1024);ws.send(buffer);
4. Message Boundaries and Framing
SSE: Built-in message framing
// Each event is discrete and completeeventSource.onmessage = (event) => { // event.data contains one complete message // No need to handle partial messages};
WebSocket: Messages always complete but need structure
// WebSocket ensures message boundaries but not structurews.onmessage = (event) => { try { const message = JSON.parse(event.data); // Handle based on message type switch(message.type) { case 'chat': handleChat(message); break; case 'status': handleStatus(message); break; } } catch (e) { console.error('Invalid message format'); }};
5. HTTP/2 Benefits
SSE: Full HTTP/2 multiplexing
// SSE over HTTP/2: Multiple streams, one connectionconst events1 = new EventSource('/events/stream1');const events2 = new EventSource('/events/stream2');const events3 = new EventSource('/events/stream3');// All share single HTTP/2 connection!
WebSocket: No HTTP/2 benefits
// Each WebSocket needs separate TCP connectionconst ws1 = new WebSocket('wss://example.com/socket1');const ws2 = new WebSocket('wss://example.com/socket2');// Two separate TCP connections required
Use Case Analysis
When to Use SSE
β Perfect for:
- Live news feeds
- Stock price updates
- Server monitoring dashboards
- Social media feeds
- Notification systems
- Progress indicators
- Live sports scores
- Log streaming
Example: Live Dashboard
// SSE excels at server-push dashboardsconst dashboard = new EventSource('/api/metrics');
dashboard.addEventListener('cpu', (e) => { updateCPUChart(JSON.parse(e.data));});
dashboard.addEventListener('memory', (e) => { updateMemoryChart(JSON.parse(e.data));});
dashboard.addEventListener('requests', (e) => { updateRequestCount(JSON.parse(e.data));});
// Automatic reconnection ensures reliabilitydashboard.onerror = () => { showReconnectingIndicator();};
When to Use WebSockets
β Perfect for:
- Chat applications
- Multiplayer games
- Collaborative editing
- Video/audio signaling
- Remote control systems
- Trading platforms
- IoT device control
- Real-time location sharing
Example: Chat Application
// WebSocket needed for bidirectional chatconst chat = new WebSocket('wss://chat.example.com');
// Send messagesfunction sendMessage(text) { chat.send(JSON.stringify({ type: 'message', text: text, timestamp: Date.now() }));}
// Receive messageschat.onmessage = (event) => { const msg = JSON.parse(event.data); displayMessage(msg);};
// Send typing indicatorsfunction sendTyping() { chat.send(JSON.stringify({ type: 'typing', user: currentUser }));}
The Hybrid Approach
Sometimes using both makes sense:
// Use SSE for server broadcastsconst broadcasts = new EventSource('/api/broadcasts');broadcasts.onmessage = (e) => { showNotification(e.data);};
// Use WebSocket for interactive featuresconst interactive = new WebSocket('wss://api.example.com/interactive');interactive.onmessage = handleInteractiveMessage;interactive.send(JSON.stringify({ action: 'subscribe', room: 'lobby' }));
// Use regular HTTP for standard operationsasync function updateProfile(data) { await fetch('/api/profile', { method: 'PUT', body: JSON.stringify(data) });}
Implementation Examples
Server Implementation
// SSE Serverconst express = require('express');const app = express();
app.get('/events', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
// Send initial data res.write('data: Connected\n\n');
// Send periodic updates const interval = setInterval(() => { const data = JSON.stringify({ time: new Date().toISOString(), value: Math.random() }); res.write(`data: ${data}\n\n`); }, 1000);
// Send named events setTimeout(() => { res.write('event: special\n'); res.write('data: Special event occurred\n\n'); }, 5000);
// Cleanup on disconnect req.on('close', () => { clearInterval(interval); });});
// WebSocket Server (for comparison)const WebSocket = require('ws');const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => { ws.on('message', (message) => { // Echo to all clients wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); });});
app.listen(3000);
# SSE Server with Flaskfrom flask import Flask, Responseimport jsonimport time
app = Flask(__name__)
def generate_events(): """Generator function for SSE""" count = 0 while True: count += 1
# Regular message data = json.dumps({ 'count': count, 'time': time.time() }) yield f"data: {data}\n\n"
# Named event every 5 messages if count % 5 == 0: yield f"event: milestone\n" yield f"data: Reached {count} messages\n\n"
# Heartbeat comment if count % 10 == 0: yield ": keepalive\n\n"
time.sleep(1)
@app.route('/events')def events(): return Response( generate_events(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' # Disable Nginx buffering } )
# WebSocket with asyncio (for comparison)import asyncioimport websockets
async def websocket_handler(websocket, path): async for message in websocket: # Broadcast to all connected clients await asyncio.gather(*[ client.send(message) for client in connected_clients ])
if __name__ == '__main__': app.run(threaded=True)
package main
import ( "encoding/json" "fmt" "net/http" "time")
// SSE Serverfunc sseHandler(w http.ResponseWriter, r *http.Request) { // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return }
// Send events ticker := time.NewTicker(1 * time.Second) defer ticker.Stop()
count := 0 for { select { case <-r.Context().Done(): return case <-ticker.C: count++
// Regular data message data := map[string]interface{}{ "count": count, "time": time.Now().Unix(), } jsonData, _ := json.Marshal(data) fmt.Fprintf(w, "data: %s\n\n", jsonData)
// Named event if count%5 == 0 { fmt.Fprintf(w, "event: milestone\n") fmt.Fprintf(w, "data: Count reached %d\n\n", count) }
// Send message ID for resume fmt.Fprintf(w, "id: %d\n\n", count)
flusher.Flush() } }}
// WebSocket handler (for comparison)func wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close()
for { messageType, p, err := conn.ReadMessage() if err != nil { return }
// Broadcast to all clients for client := range clients { client.WriteMessage(messageType, p) } }}
func main() { http.HandleFunc("/events", sseHandler) http.HandleFunc("/ws", wsHandler) http.ListenAndServe(":3000", nil)}
Client Implementation
// SSE Client with Reconnection and Event Handlingclass SSEClient { constructor(url) { this.url = url; this.eventSource = null; this.listeners = new Map(); this.reconnectTime = 1000; this.lastEventId = null; }
connect() { // Include last event ID for resume const url = this.lastEventId ? `${this.url}?lastEventId=${this.lastEventId}` : this.url;
this.eventSource = new EventSource(url);
this.eventSource.onopen = () => { console.log('SSE Connected'); this.reconnectTime = 1000; // Reset backoff };
this.eventSource.onmessage = (event) => { this.lastEventId = event.lastEventId; this.emit('message', event.data); };
this.eventSource.onerror = (event) => { if (event.target.readyState === EventSource.CLOSED) { console.log('SSE Disconnected'); // EventSource will auto-reconnect } };
// Register named event listeners this.listeners.forEach((callback, eventName) => { this.eventSource.addEventListener(eventName, (event) => { this.lastEventId = event.lastEventId; callback(event.data); }); }); }
on(eventName, callback) { this.listeners.set(eventName, callback); if (this.eventSource) { this.eventSource.addEventListener(eventName, (event) => { callback(event.data); }); } }
emit(eventName, data) { const callback = this.listeners.get(eventName); if (callback) callback(data); }
close() { if (this.eventSource) { this.eventSource.close(); } }}
// Usageconst client = new SSEClient('/api/events');
client.on('message', (data) => { console.log('Received:', data);});
client.on('notification', (data) => { showNotification(JSON.parse(data));});
client.connect();
// Custom React Hook for SSEimport { useEffect, useState, useRef } from 'react';
function useServerSentEvents(url, options = {}) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [readyState, setReadyState] = useState(0); const eventSourceRef = useRef(null);
useEffect(() => { const eventSource = new EventSource(url, options); eventSourceRef.current = eventSource;
eventSource.onopen = () => { setReadyState(EventSource.OPEN); setError(null); };
eventSource.onmessage = (event) => { try { const parsedData = JSON.parse(event.data); setData(parsedData); } catch (e) { setData(event.data); } };
eventSource.onerror = (event) => { setReadyState(eventSource.readyState); if (eventSource.readyState === EventSource.CLOSED) { setError('Connection closed'); } else { setError('Connection error'); } };
// Custom event listeners if (options.events) { Object.entries(options.events).forEach(([eventName, handler]) => { eventSource.addEventListener(eventName, (event) => { handler(event.data); }); }); }
return () => { eventSource.close(); }; }, [url]);
return { data, error, readyState, eventSource: eventSourceRef.current };}
// Usage in componentfunction LiveDashboard() { const { data, error, readyState } = useServerSentEvents('/api/metrics', { events: { alert: (data) => { console.error('Alert:', data); }, update: (data) => { console.log('Update:', data); } } });
if (error) return <div>Error: {error}</div>; if (readyState === EventSource.CONNECTING) return <div>Connecting...</div>;
return ( <div> <h2>Live Metrics</h2> <pre>{JSON.stringify(data, null, 2)}</pre> </div> );}
Browser Quirks and Limitations
Connection Limits
SSE: 6 connections per domain (HTTP/1.1)
// Problem: Browser limits SSE connectionsconst stream1 = new EventSource('/events/1'); // βconst stream2 = new EventSource('/events/2'); // βconst stream3 = new EventSource('/events/3'); // βconst stream4 = new EventSource('/events/4'); // βconst stream5 = new EventSource('/events/5'); // βconst stream6 = new EventSource('/events/6'); // βconst stream7 = new EventSource('/events/7'); // β Blocked!
// Solution 1: Use HTTP/2// HTTP/2 multiplexes all streams over one connection
// Solution 2: Domain shardingconst stream1 = new EventSource('https://events1.example.com/stream');const stream2 = new EventSource('https://events2.example.com/stream');
// Solution 3: Multiplex through single connectionconst events = new EventSource('/events/all');events.onmessage = (e) => { const { channel, data } = JSON.parse(e.data); routeToHandler(channel, data);};
WebSocket: No browser limit
// WebSocket has no connection limitfor (let i = 0; i < 100; i++) { const ws = new WebSocket(`wss://example.com/socket/${i}`); // All 100 connections work (server permitting)}
Mobile Browser Behavior
SSE on Mobile:
// SSE may disconnect on mobile backgrounddocument.addEventListener('visibilitychange', () => { if (document.hidden) { // Page backgrounded - SSE may disconnect console.log('App backgrounded'); } else { // Page foregrounded - SSE will reconnect automatically console.log('App foregrounded - SSE reconnecting'); }});
// iOS Safari specific workaroundlet keepAliveInterval;const eventSource = new EventSource('/events');
document.addEventListener('visibilitychange', () => { if (!document.hidden) { // Ensure connection is alive when returning keepAliveInterval = setInterval(() => { fetch('/keepalive'); }, 30000); } else { clearInterval(keepAliveInterval); }});
CORS Differences
SSE: Standard CORS
// SSE follows normal CORS rulesconst eventSource = new EventSource('https://other-domain.com/events', { withCredentials: true // Include cookies});
// Server must include CORS headers// Access-Control-Allow-Origin: https://your-domain.com// Access-Control-Allow-Credentials: true
WebSocket: Origin checking only
// WebSocket doesn't use CORS, only Origin headerconst ws = new WebSocket('wss://other-domain.com/socket');// Server should validate Origin header manually
When to Choose Which
Choose SSE When:
β You only need server-to-client communication
- Live feeds and notifications
- Progress updates
- Monitoring dashboards
- Log streaming
β You want automatic reconnection
- Unreliable networks
- Mobile applications
- Critical update streams
β You need HTTP/2 benefits
- Multiple event streams
- Existing HTTP/2 infrastructure
- CDN compatibility
β Simplicity is important
- Quick prototypes
- Simple event streaming
- Limited client resources
Choose WebSockets When:
β You need bidirectional communication
- Chat applications
- Multiplayer games
- Collaborative editing
- Remote control
β You need binary data support
- File transfers
- Audio/video streaming
- Binary protocols
- IoT sensor data
β You need lowest latency
- Trading platforms
- Gaming
- Real-time control systems
β You have complex interaction patterns
- Request-response over same connection
- Multiple message types
- Stateful protocols
Conclusion
Server-Sent Events and WebSockets each excel in different scenarios. SSE shines with its simplicity, automatic reconnection, and HTTP/2 compatibility, making it perfect for server-push notifications and live feeds. WebSockets provide the bidirectional communication and binary support needed for interactive applications like chat and gaming.
Key Takeaways:
- Use SSE for unidirectional server-to-client streaming with automatic reconnection
- Use WebSockets for bidirectional communication and binary data
- SSE is simpler to implement and debug
- WebSockets are more flexible but require more complexity
- Consider HTTP/2 when using SSE for maximum efficiency
- Both can coexist in the same application for different features
The choice often comes down to your specific requirements: if you only need server push with reliability, SSE is likely the better choice. If you need any form of client-to-server communication beyond the initial request, WebSockets become necessary.
Further Reading
- Server-Sent Events Specification
- EventSource MDN Documentation
- WebSocket Protocol RFC 6455
- Building a WebSocket Application
- WebSocket Security Guide
While raw SSE or WebSocket implementation is straightforward, production applications often benefit from using established libraries like Socket.IO or commercial services that provide automatic protocol selection, handle reconnection logic, and manage scaling complexities.
Written by Matthew OβRiordan, Co-founder & CEO of Ably, with experience building real-time systems reaching 2 billion+ devices monthly.