Skip to content

WebSocket vs SSE: Which One Should You Use?

:::note[Quick Answer] Use SSE for simple server-to-client streaming (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.

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

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');
} else {
console.log('Connection error, will retry');
}
};
// Named events
eventSource.addEventListener('user-login', (event) => {
console.log('User logged in:', event.data);
});

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

WebSockets create a full-duplex communication channel through an HTTP upgrade handshake.

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));
// 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: Both send and receive on same connection
ws.onmessage = (event) => {
updateUI(event.data);
};
ws.send(
JSON.stringify({
action: 'user-input',
data: 'Hello',
})
);
// No code needed! EventSource handles it automatically
const eventSource = new EventSource('/events');
// Server can set retry interval
// retry: 5000
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
};
}
}
// 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: 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);
// 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

Section titled “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');
}
};
// 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');
// 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,
})
);
}

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

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', () => {
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);
}
});
// 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

AI and LLM Streaming: A Shifting Landscape

Section titled “AI and LLM Streaming: A Shifting Landscape”

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:

  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.

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.

What is the difference between WebSocket and SSE?

Section titled “What is the difference between WebSocket and SSE?”

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.

When should I use SSE instead of WebSocket?

Section titled “When should I use SSE instead of WebSocket?”

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.

Can SSE handle as many connections as WebSocket?

Section titled “Can SSE handle as many connections as WebSocket?”

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.

Is SSE or WebSocket better for AI/LLM token streaming?

Section titled “Is SSE or WebSocket better for AI/LLM token streaming?”

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.