(notifications, live feeds, AI token streaming) - it auto-reconnects and works
over HTTP. Use WebSocket when you need bidirectional communication (chat,
gaming, collaborative editing). SSE is simpler; WebSocket is more capable. :::
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.
| 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 |
SSE uses a persistent HTTP connection to stream events from server to client
using a simple text-based format.
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');
console.log('Connection error, will retry');
eventSource.addEventListener('user-login', (event) => {
console.log('User logged in:', event.data);
The SSE protocol uses a simple text format:
Content-Type: text/event-stream
data: {"username": "alice"}
: This is a comment (heartbeat)
Each message is separated by double newlines (\n\n), with fields including:
data: - The message payload
event: - Event type for named events
id: - Message ID for resumption
retry: - Reconnection time in milliseconds
: - Comments (often used for keepalive)
|-------- GET /events --------->|
| (Includes Last-Event-ID if |
|<-- HTTP 200 text/event-stream -|
|<---- data: message 1\n\n ------|
|<---- data: message 2\n\n ------|
|<------ : keepalive\n\n -------|
|-- GET /events (Last-Event-ID) >|
| (Automatic reconnection) |
|<--- Resume from message 3 -----|
WebSockets create a full-duplex communication channel through an HTTP upgrade
handshake.
const ws = new WebSocket('wss://example.com/socket');
console.log('Connected');
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 types
ws.send(JSON.stringify({ type: 'json' }));
ws.send(new Blob(['binary data']));
ws.send(new ArrayBuffer(8));
// SSE: Client can only receive
eventSource.onmessage = (event) => {
// To send data, need separate HTTP request
async function sendToServer(data) {
await fetch('/api/action', {
body: JSON.stringify(data),
// WebSocket: Both send and receive on same connection
ws.onmessage = (event) => {
// No code needed! EventSource handles it automatically
const eventSource = new EventSource('/events');
// Server can set retry interval
class ReconnectingWebSocket {
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.ws = new WebSocket(this.url);
this.ws.onclose = () => {
if (this.shouldReconnect) {
this.reconnectDelay *= 2; // Exponential backoff
this.reconnectDelay = 1000; // Reset delay
// SSE: Must serialize binary data
eventSource.onmessage = (event) => {
// event.data is always a string
// For binary, need base64 encoding
const binary = atob(event.data);
// WebSocket: Native binary support
ws.binaryType = 'arraybuffer'; // or 'blob'
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
const buffer = new ArrayBuffer(1024);
// Each event is discrete and complete
eventSource.onmessage = (event) => {
// event.data contains one complete message
// No need to handle partial messages
// WebSocket ensures message boundaries but not structure
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
// Handle based on message type
console.error('Invalid message format');
// SSE over HTTP/2: Multiple streams, one connection
const events1 = new EventSource('/events/stream1');
const events2 = new EventSource('/events/stream2');
const events3 = new EventSource('/events/stream3');
// All share single HTTP/2 connection!
// Each WebSocket needs separate TCP connection
const ws1 = new WebSocket('wss://example.com/socket1');
const ws2 = new WebSocket('wss://example.com/socket2');
// Two separate TCP connections required
✅ Perfect for:
- Live news feeds
- Stock price updates
- Server monitoring dashboards
- Social media feeds
- Notification systems
- Progress indicators
- Live sports scores
- Log streaming
// SSE excels at server-push dashboards
const 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 reliability
dashboard.onerror = () => {
showReconnectingIndicator();
✅ Perfect for:
- Chat applications
- Multiplayer games
- Collaborative editing
- Video/audio signaling
- Remote control systems
- Trading platforms
- IoT device control
- Real-time location sharing
// WebSocket needed for bidirectional chat
const chat = new WebSocket('wss://chat.example.com');
function sendMessage(text) {
chat.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Send typing indicators
Sometimes using both makes sense:
// Use SSE for server broadcasts
const broadcasts = new EventSource('/api/broadcasts');
broadcasts.onmessage = (e) => {
showNotification(e.data);
// Use WebSocket for interactive features
const interactive = new WebSocket('wss://api.example.com/interactive');
interactive.onmessage = handleInteractiveMessage;
interactive.send(JSON.stringify({ action: 'subscribe', room: 'lobby' }));
// Use regular HTTP for standard operations
async function updateProfile(data) {
await fetch('/api/profile', {
body: JSON.stringify(data),
const express = require('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); } }); }); });
from flask import Flask, Response
"""Generator function for SSE"""
yield f"data: {data}\n\n"
# Named event every 5 messages
yield f"event: milestone\n"
yield f"data: Reached {count} messages\n\n"
mimetype='text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' # Disable Nginx buffering
# WebSocket with asyncio (for comparison)
async def websocket_handler(websocket, path):
async for message in websocket:
# Broadcast to all connected clients
for client in connected_clients
if __name__ == '__main__':
// SSE Server func 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)
http.Error(w, "SSE not supported", http.StatusInternalServerError)
ticker := time.NewTicker(1 * time.Second)
case <-r.Context().Done():
data := map[string]interface{}{
"time": time.Now().Unix(),
jsonData, _ := json.Marshal(data)
fmt.Fprintf(w, "data: %s\n\n", jsonData)
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)
// 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()
messageType, p, err := conn.ReadMessage()
// 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) }
// SSE Client with Reconnection and Event Handling
this.listeners = new Map();
this.reconnectTime = 1000;
// Include last event ID for resume
const url = this.lastEventId
? `${this.url}?lastEventId=${this.lastEventId}`
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;
on(eventName, callback) {
this.listeners.set(eventName, callback);
this.eventSource.addEventListener(eventName, (event) => {
const callback = this.listeners.get(eventName);
if (callback) callback(data);
this.eventSource.close();
const client = new SSEClient('/api/events');
client.on('message', (data) => {
console.log('Received:', data);
client.on('notification', (data) => {
showNotification(JSON.parse(data));
// Custom React Hook for SSE
import { 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);
eventSource.onmessage = (event) => {
const parsedData = JSON.parse(event.data);
eventSource.onerror = (event) => {
setReadyState(eventSource.readyState);
if (eventSource.readyState === EventSource.CLOSED) {
setError('Connection closed');
setError('Connection error');
// Custom event listeners
Object.entries(options.events).forEach(([eventName, handler]) => {
eventSource.addEventListener(eventName, (event) => {
return { data, error, readyState, eventSource: eventSourceRef.current }; }
// Usage in component function LiveDashboard() { const { data, error, readyState
} = useServerSentEvents('/api/metrics', { events: { alert: (data) => {
console.error('Alert:', data); }, update: (data) => { console.log('Update:',
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>
SSE: 6 connections per domain (HTTP/1.1)
// Problem: Browser limits SSE connections
const 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 sharding
const stream1 = new EventSource('https://events1.example.com/stream');
const stream2 = new EventSource('https://events2.example.com/stream');
// Solution 3: Multiplex through single connection
const events = new EventSource('/events/all');
events.onmessage = (e) => {
const { channel, data } = JSON.parse(e.data);
routeToHandler(channel, data);
// WebSocket has no connection limit
for (let i = 0; i < 100; i++) {
const ws = new WebSocket(`wss://example.com/socket/${i}`);
// All 100 connections work (server permitting)
SSE on Mobile:
// SSE may disconnect on mobile background
document.addEventListener('visibilitychange', () => {
// Page backgrounded - SSE may disconnect
console.log('App backgrounded');
// Page foregrounded - SSE will reconnect automatically
console.log('App foregrounded - SSE reconnecting');
// iOS Safari specific workaround
const eventSource = new EventSource('/events');
document.addEventListener('visibilitychange', () => {
// Ensure connection is alive when returning
keepAliveInterval = setInterval(() => {
clearInterval(keepAliveInterval);
// SSE follows normal CORS rules
const 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 doesn't use CORS, only Origin header
const ws = new WebSocket('wss://other-domain.com/socket');
// Server should validate Origin header manually
SSE and HTTP streaming can be silently buffered by corporate proxies,
firewalls, and some CDN configurations. The connection appears open, but
events arrive in batches instead of real-time — or not at all until the
buffer fills or the connection closes.
This is particularly common in:
- Corporate networks with SSL-inspecting proxies
- Environments using legacy HTTP/1.0 proxies that don’t understand
chunked transfer encoding
- Some cloud WAF configurations
At Ably,
we’ve seen this repeatedly — customers migrate from SSE to WebSockets
after discovering their SSE connections work in development but buffer
unpredictably in production environments. Our own SSE transport fallback
encounters the same issue in restrictive networks.
The X-Accel-Buffering: no header helps with Nginx (already shown in
the Python server example above), but it can’t solve buffering by
intermediaries you don’t control. If your users are in corporate
environments, test SSE delivery latency there specifically — don’t
assume dev environment behavior matches production.
WebSockets avoid this problem because the HTTP upgrade switches to a
different protocol that proxies either pass through or block entirely
— there’s no silent buffering.
✅ 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
✅ 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
One of the most significant recent developments in the SSE vs WebSockets debate
comes from AI applications. When LLM-powered chatbots first emerged, SSE was the
natural choice for streaming tokens from server to client - it’s simple and does
server-push well.
However, as AI applications have matured beyond basic chat into agent workflows,
human-in-the-loop approval, and multi-device interactions, teams are
increasingly adopting WebSockets. The core issue is bidirectionality: modern AI
interactions need the client to send signals back to the server during a session
(cancelling generation, approving tool calls, steering agents), which SSE cannot
provide over the same connection.
This shift is reflected in the ecosystem. The Vercel AI SDK deprecated its
HTTP+SSE transport in favor of a pluggable transport interface. The MCP protocol
moved away from SSE. And an emerging infrastructure category called
Durable Sessions is building persistent,
resumable session layers on top of WebSockets to handle the demands of complex
AI workflows.
For a deeper look at this trend, see our guide on
WebSockets and AI.
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.
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.
WebSocket provides full-duplex bidirectional communication over a single TCP
connection. SSE (Server-Sent Events) is unidirectional, streaming only from
server to client, and runs over standard HTTP. SSE has built-in reconnection and
is simpler to implement, but WebSocket is needed when the client must also send
data to the server.
Use SSE when you only need server-to-client streaming, like live feeds,
notifications, or real-time dashboards. SSE is simpler, works with HTTP/2
multiplexing, reconnects automatically, and works through most proxies and
firewalls without special configuration.
Under HTTP/1.1, browsers limit SSE to 6 connections per domain. HTTP/2 removes
this limit by multiplexing streams. WebSocket has no browser-imposed connection
limit under either protocol version, making it better suited for applications
that need many concurrent connections.
SSE is the most common choice for AI token streaming because the data flows in
one direction (server to client) and SSE is simpler to implement. OpenAI,
Anthropic, and most LLM APIs use SSE for streaming responses. WebSocket is
better if you need bidirectional interaction during generation.
Written by Matthew O’Riordan, Co-founder & CEO of
Ably, with experience building real-time systems reaching 2 billion+ devices
monthly.