Skip to content

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

FeatureServer-Sent EventsWebSockets
DirectionServer β†’ Client onlyBidirectional
ProtocolHTTP/1.1 or HTTP/2WebSocket (after HTTP upgrade)
Automatic Reconnectionβœ… Built-in❌ Manual implementation
Connection Limit6 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 Support97%99%+
Message Framingβœ… Built-in with IDs❌ Manual implementation
ComplexityLowMedium

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 events
eventSource.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 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: First message
data: Second message
id: 2
event: user-login
data: {"username": "alice"}
: This is a comment (heartbeat)
data: Multi-line message
data: Line 2
data: Line 3
retry: 5000

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)

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 types
ws.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 receive
eventSource.onmessage = (event) => {
updateUI(event.data);
};
// To send data, need separate HTTP request
async function sendToServer(data) {
await fetch('/api/action', {
method: 'POST',
body: JSON.stringify(data)
});
}

WebSocket: Bidirectional

// WebSocket: Both send and receive on same connection
ws.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 automatically
const 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 data
eventSource.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 support
ws.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 directly
const buffer = new ArrayBuffer(1024);
ws.send(buffer);

4. Message Boundaries and Framing

SSE: Built-in message framing

// Each event is discrete and complete
eventSource.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 structure
ws.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 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!

WebSocket: No HTTP/2 benefits

// 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

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 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();
};

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 chat
const chat = new WebSocket('wss://chat.example.com');
// Send messages
function sendMessage(text) {
chat.send(JSON.stringify({
type: 'message',
text: text,
timestamp: Date.now()
}));
}
// Receive messages
chat.onmessage = (event) => {
const msg = JSON.parse(event.data);
displayMessage(msg);
};
// Send typing indicators
function sendTyping() {
chat.send(JSON.stringify({
type: 'typing',
user: currentUser
}));
}

The Hybrid Approach

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', {
method: 'PUT',
body: JSON.stringify(data)
});
}

Implementation Examples

Server Implementation

// SSE Server
const 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);

Client Implementation

// SSE Client with Reconnection and Event Handling
class 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();
}
}
}
// Usage
const client = new SSEClient('/api/events');
client.on('message', (data) => {
console.log('Received:', data);
});
client.on('notification', (data) => {
showNotification(JSON.parse(data));
});
client.connect();

Browser Quirks and Limitations

Connection Limits

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: No browser limit

// 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)
}

Mobile Browser Behavior

SSE on Mobile:

// SSE may disconnect on mobile background
document.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 workaround
let 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 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: Origin checking only

// WebSocket doesn't use CORS, only Origin header
const 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:

  1. Use SSE for unidirectional server-to-client streaming with automatic reconnection
  2. Use WebSockets for bidirectional communication and binary data
  3. SSE is simpler to implement and debug
  4. WebSockets are more flexible but require more complexity
  5. Consider HTTP/2 when using SSE for maximum efficiency
  6. 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

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.