WebSocket vs Server-Sent Events: Choosing the Right Real-Time Technology for Production
WebSocket and SSE both solve real-time communication, but they're not interchangeable. Understanding the architectural differences prevents performance bottlenecks and operational complexity.
Executive summary
WebSocket and SSE both solve real-time communication, but they're not interchangeable. Understanding the architectural differences prevents performance bottlenecks and operational complexity.
Last updated: 3/10/2026
Executive summary
Real-time functionality—live updates, push notifications, collaborative editing—has moved from "nice-to-have" to table stakes for modern web applications. The two dominant approaches for browser-to-server real-time communication are WebSocket and Server-Sent Events (SSE).
The engineering decision isn't just technical; it's architectural. WebSocket provides full-duplex communication, while SSE is unidirectional server-to-client push. Both have distinct scaling characteristics, operational requirements, and failure modes. Choosing the wrong one leads to unnecessary infrastructure complexity, security vulnerabilities, or poor user experience.
Connection models: Understanding the fundamental difference
WebSocket: Full-duplex bidirectional communication
WebSocket upgrades an HTTP connection to a persistent TCP socket that allows data to flow in both directions simultaneously.
typescript// WebSocket client implementation
class RealtimeChatClient {
private ws: WebSocket;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 5;
private readonly RECONNECT_DELAY_MS = 3000;
constructor(private url: string) {
this.ws = this.connect();
}
private connect(): WebSocket {
const ws = new WebSocket(this.url);
ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
// Send authentication
this.sendMessage({
type: 'auth',
token: this.getAuthToken()
});
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
if (!event.wasClean && this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++;
setTimeout(() => {
this.ws = this.connect();
}, this.RECONNECT_DELAY_MS);
}
};
return ws;
}
sendMessage(message: any) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.error('WebSocket is not open');
}
}
private handleMessage(message: any) {
switch (message.type) {
case 'chat_message':
this.displayMessage(message.data);
break;
case 'presence_update':
this.updatePresence(message.data);
break;
case 'typing_indicator':
this.showTypingIndicator(message.data);
break;
}
}
private getAuthToken(): string {
return localStorage.getItem('auth_token') || '';
}
private displayMessage(data: any) {
// Update UI with new message
}
private updatePresence(data: any) {
// Update user presence indicators
}
private showTypingIndicator(data: any) {
// Show/hide typing indicator
}
}Server-Sent Events: Unidirectional server-to-client push
SSE uses a long-lived HTTP connection where the server pushes events to the client. The client cannot send data back through the SSE connection.
typescript// SSE client implementation
class LiveUpdatesClient {
private eventSource: EventSource;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 5;
constructor(private url: string) {
this.eventSource = this.connect();
}
private connect(): EventSource {
const eventSource = new EventSource(this.url);
eventSource.onopen = () => {
console.log('SSE connection established');
this.reconnectAttempts = 0;
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleUpdate(data);
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
this.eventSource.close();
if (this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++;
setTimeout(() => {
this.eventSource = this.connect();
}, 3000);
}
};
return eventSource;
}
private handleUpdate(data: any) {
switch (data.type) {
case 'price_update':
this.updatePrice(data);
break;
case 'notification':
this.showNotification(data);
break;
case 'system_status':
this.updateStatus(data);
break;
}
}
private updatePrice(data: any) {
// Update price in UI
}
private showNotification(data: any) {
// Display toast notification
}
private updateStatus(data: any) {
// Update system status indicator
}
// Note: Cannot send data back through SSE connection
// Use fetch/HTTP for client-to-server communication
}Comparison matrix: When to use which
| Criterion | WebSocket | SSE (Server-Sent Events) |
|---|---|---|
| Directionality | Bidirectional (server ↔ client) | Unidirectional (server → client only) |
| Data format | Binary or text | Text-only (typically JSON) |
| Browser support | Excellent (all modern browsers) | Excellent (all modern browsers) |
| Proxy compatibility | May require special configuration | Works through most proxies |
| Reconnection | Manual implementation required | Automatic with exponential backoff |
| Event types | Custom message types | Named event channels |
| Connection overhead | Initial HTTP handshake + upgrade | Persistent HTTP connection |
| Scaling complexity | High (connection pooling, state management) | Moderate (stateless easier) |
| Use case fit | Chat, gaming, collaborative editing | Live updates, notifications, stock tickers |
Use case analysis
When WebSocket is the right choice
1. Real-time chat applications
Chat requires bidirectional communication: users send messages and receive messages instantly. WebSocket's full-duplex nature is ideal.
typescript// Chat application using WebSocket
class ChatApplication {
private ws: WebSocket;
private currentUser: User;
async sendMessage(content: string) {
this.ws.send(JSON.stringify({
type: 'send_message',
data: {
userId: this.currentUser.id,
content,
timestamp: Date.now()
}
}));
}
private handleIncomingMessage(message: ChatMessage) {
// Display incoming message
// Update unread count
// Trigger notification if needed
}
async setTypingStatus(isTyping: boolean) {
this.ws.send(JSON.stringify({
type: 'typing_status',
data: {
userId: this.currentUser.id,
isTyping,
timestamp: Date.now()
}
}));
}
private handleTypingStatus(status: TypingStatus) {
// Show/hide typing indicator
}
}2. Multiplayer gaming
Games require frequent, low-latency bidirectional communication with multiple concurrent events.
3. Collaborative editing
Tools like Google Docs or Figma require real-time synchronization of user actions across all participants.
When SSE is the right choice
1. Live financial data updates
Stock prices, cryptocurrency values, or any financial tickers only need server-to-client push.
typescript// Financial ticker using SSE
class StockTickerClient {
private eventSource: EventSource;
private subscribedSymbols: Set<string> = new Set();
async subscribe(symbols: string[]) {
// Use HTTP POST to subscribe (not SSE)
await fetch('/api/stocks/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols })
});
symbols.forEach(symbol => this.subscribedSymbols.add(symbol));
}
async unsubscribe(symbols: string[]) {
await fetch('/api/stocks/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols })
});
symbols.forEach(symbol => this.subscribedSymbols.delete(symbol));
}
private handlePriceUpdate(update: PriceUpdate) {
if (this.subscribedSymbols.has(update.symbol)) {
this.updateDisplay(update);
}
}
private updateDisplay(update: PriceUpdate) {
// Update price in UI
// Highlight price change (green/red)
// Update chart
}
}2. Real-time notifications
Push notifications, alerts, or system status updates flow from server to client only.
3. Live dashboard monitoring
Operational dashboards displaying metrics, logs, or system status only need server-to-client updates.
Scaling considerations
WebSocket scaling challenges
WebSocket connections are stateful and persistent, which creates scaling complexity:
1. Connection pooling and state management
typescript// WebSocket server with connection pool
class WebSocketServer {
private connections: Map<string, WebSocket> = new Map();
private userRooms: Map<string, Set<string>> = new Map(); // userId -> roomIds
handleConnection(userId: string, ws: WebSocket) {
this.connections.set(userId, ws);
ws.on('message', (message) => {
const data = JSON.parse(message);
this.handleMessage(userId, data);
});
ws.on('close', () => {
this.connections.delete(userId);
this.removeUserFromAllRooms(userId);
});
}
broadcastToRoom(roomId: string, message: any) {
const usersInRoom = this.getUserIdsInRoom(roomId);
usersInRoom.forEach(userId => {
const ws = this.connections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
});
}
private getUserIdsInRoom(roomId: string): string[] {
// Return all user IDs in the room
return Array.from(this.userRooms.entries())
.filter(([_, rooms]) => rooms.has(roomId))
.map(([userId, _]) => userId);
}
private removeUserFromAllRooms(userId: string) {
const rooms = this.userRooms.get(userId);
if (rooms) {
rooms.forEach(roomId => this.leaveRoom(userId, roomId));
}
this.userRooms.delete(userId);
}
}2. Load balancer compatibility
WebSocket connections require load balancers that support sticky sessions or implement WebSocket-aware routing.
3. Horizontal scaling across multiple servers
When WebSocket connections are distributed across multiple servers, inter-server communication is required for broadcasting:
typescript// Redis-based pub/sub for cross-server WebSocket broadcasting
class DistributedWebSocketServer {
private connections: Map<string, WebSocket> = new Map();
private redisPublisher: Redis;
private redisSubscriber: Redis;
constructor(private serverId: string) {
this.redisSubscriber.subscribe('websocket:broadcast');
this.redisSubscriber.on('message', (channel, message) => {
this.handleBroadcastMessage(JSON.parse(message));
});
}
async broadcastToAll(message: any) {
// Publish to Redis for all servers to receive
await this.redisPublisher.publish('websocket:broadcast', JSON.stringify({
serverId: this.serverId,
message
}));
}
private handleBroadcastMessage(data: any) {
// Don't rebroadcast if message originated from this server
if (data.serverId === this.serverId) {
return;
}
// Send to all connected clients on this server
this.connections.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data.message));
}
});
}
}SSE scaling advantages
SSE is easier to scale because:
1. Stateless server design
SSE servers can be stateless and horizontally scaled behind standard load balancers.
2. Built-in reconnection
The EventSource API automatically handles reconnection with exponential backoff, reducing server-side complexity.
3. Simple horizontal scaling
Multiple SSE servers can serve different clients without inter-server communication required for broadcasting.
typescript// Stateless SSE server
class SSEServer {
private clients: Map<http.ServerResponse, Set<string>> = new Map();
handleSSEConnection(req: http.IncomingMessage, res: http.ServerResponse) {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
});
// Track client and their subscriptions
this.clients.set(res, new Set());
// Send initial connection message
this.sendEvent(res, { type: 'connected', timestamp: Date.now() });
req.on('close', () => {
this.clients.delete(res);
});
}
sendEvent(res: http.ServerResponse, data: any) {
const event = `data: ${JSON.stringify(data)}\n\n`;
res.write(event);
}
broadcastToAll(message: any) {
this.clients.forEach((_, res) => {
this.sendEvent(res, message);
});
}
}Security considerations
WebSocket security
1. Authentication during handshake
typescript// WebSocket authentication middleware
const authenticateWebSocket = async (req: http.IncomingRequest): Promise<User | null> => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return null;
}
try {
const decoded = await verifyJWT(token);
return await User.findById(decoded.userId);
} catch (error) {
return null;
}
};
// WebSocket server with authentication
wss.on('connection', async (ws: WebSocket, req: http.IncomingRequest) => {
const user = await authenticateWebSocket(req);
if (!user) {
ws.close(4008, 'Unauthorized');
return;
}
// Store user connection
connections.set(user.id, ws);
ws.on('message', (message) => {
handleUserMessage(user.id, message);
});
});2. Rate limiting and abuse prevention
WebSocket connections can be abused for DoS attacks or resource exhaustion.
typescript// Rate limiting for WebSocket messages
class WebSocketRateLimiter {
private messageCounts: Map<string, number[]> = new Map();
private readonly WINDOW_MS = 60000; // 1 minute
private readonly MAX_MESSAGES = 100;
checkRateLimit(userId: string): boolean {
const now = Date.now();
const userMessages = this.messageCounts.get(userId) || [];
// Remove messages outside the time window
const recentMessages = userMessages.filter(timestamp => now - timestamp < this.WINDOW_MS);
if (recentMessages.length >= this.MAX_MESSAGES) {
return false;
}
recentMessages.push(now);
this.messageCounts.set(userId, recentMessages);
return true;
}
}SSE security
1. Authentication via query parameters or cookies
typescript// SSE server with authentication
const handleSSEConnection = async (req: http.IncomingRequest, res: http.ServerResponse) => {
// Extract token from cookie or query parameter
const token = req.headers.cookie?.match(/auth_token=([^;]+)/)?.[1]
|| new URL(req.url || '', 'http://localhost').searchParams.get('token');
if (!token) {
res.writeHead(401);
res.end();
return;
}
const user = await authenticateToken(token);
if (!user) {
res.writeHead(401);
res.end();
return;
}
// Establish SSE connection
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send initial event
res.write(`data: ${JSON.stringify({ type: 'connected', userId: user.id })}\n\n`);
// Continue sending events...
};2. Authorization and scope-based filtering
Clients should only receive events they're authorized to see.
Performance and resource considerations
WebSocket resource usage
Connection overhead:
- Each WebSocket connection holds a TCP socket open
- Server memory: ~1-2KB per connection (connection state)
- Server CPU: minimal for idle connections
- Network: keep-alive packets (typically every 30-60 seconds)
Benchmarking example:
typescript// WebSocket connection stress test
async function benchmarkWebSocketConnections() {
const CONCURRENT_CONNECTIONS = 10000;
const clients: WebSocket[] = [];
console.time('connect');
for (let i = 0; i < CONCURRENT_CONNECTIONS; i++) {
const ws = new WebSocket('ws://localhost:8080');
clients.push(ws);
await new Promise(resolve => ws.onopen = resolve);
}
console.timeEnd('connect');
// Measure memory usage
const memoryUsage = process.memoryUsage();
console.log('Memory usage:', {
heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`,
heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`
});
}SSE resource usage
Connection overhead:
- Each SSE connection holds an HTTP connection open
- Server memory: ~500B-1KB per connection (less than WebSocket)
- Server CPU: minimal for idle connections
- Network: keep-alive packets (typically every 30-60 seconds)
Memory efficiency advantage: SSE connections are typically lighter on memory because they don't maintain as much state as WebSocket connections.
Hybrid approaches
In many production systems, the optimal solution combines both technologies based on use case:
typescript// Hybrid real-time client
class HybridRealtimeClient {
private sseClient: LiveUpdatesClient;
private wsClient: RealtimeChatClient;
private wsConnected = false;
constructor(private config: HybridConfig) {
// Initialize SSE for server-to-client updates
this.sseClient = new LiveUpdatesClient(config.sseUrl);
// Initialize WebSocket for bidirectional communication
this.wsClient = new RealtimeChatClient(config.wsUrl);
this.wsClient.onConnectionChange = (connected: boolean) => {
this.wsConnected = connected;
};
}
sendChatMessage(message: string) {
// Use WebSocket for sending messages
if (this.wsConnected) {
this.wsClient.sendMessage(message);
} else {
console.error('WebSocket not connected');
// Fallback: use HTTP POST
this.postMessageViaHTTP(message);
}
}
private async postMessageViaHTTP(message: string) {
await fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message })
});
}
subscribeToLiveUpdates() {
// Use SSE for receiving updates
this.sseClient.connect();
}
}Decision framework
Use this framework to choose the right technology for your use case:
Questions to ask
- Does the client need to send data to the server in real-time?
- Yes → WebSocket
- No → Consider SSE
- Is low latency critical for both directions?
- Yes → WebSocket
- No → SSE may be sufficient
- How many concurrent connections do you expect?
- < 10,000 → Both options viable
- 10,000-100,000 → SSE may scale easier
- > 100,000 → Requires specialized WebSocket infrastructure
- What's your team's operational expertise?
- Experienced with WebSocket infrastructure → WebSocket
- Prefering simpler stateless architecture → SSE
- Do you need binary data transfer?
- Yes → WebSocket
- No → SSE sufficient
Production deployment checklist
For WebSocket:
- [ ] Implement connection pooling and state management
- [ ] Configure load balancers for sticky sessions
- [ ] Implement cross-server communication for broadcasting
- [ ] Add rate limiting and abuse prevention
- [ ] Set up monitoring for connection health
- [ ] Implement graceful reconnection on client side
- [ ] Add connection timeout handling
For SSE:
- [ ] Implement authentication via cookies or query parameters
- [ ] Set proper SSE headers (Content-Type, Cache-Control)
- [ ] Configure reverse proxies to disable buffering
- [ ] Add authorization and scope-based filtering
- [ ] Implement event replay for disconnected clients
- [ ] Set up monitoring for connection health
Conclusion
WebSocket and SSE are both powerful technologies for real-time communication, but they solve different problems. WebSocket excels at bidirectional, low-latency communication where both client and server need to exchange data frequently. SSE shines in unidirectional server-to-client scenarios where simplicity and stateless scaling are priorities.
The decision isn't just about technical capabilities—it's about operational complexity, team expertise, and long-term maintainability. Choose the technology that matches your use case, scaling requirements, and operational capabilities.
Building a real-time application and need guidance on architecture decisions? Talk to Imperialis about designing and implementing the right real-time solution for your production system.
Sources
- RFC 6455: The WebSocket Protocol — WebSocket specification
- HTML Living Standard: Server-Sent Events — SSE specification
- MDN Web Docs: WebSocket API — WebSocket API documentation
- MDN Web Docs: EventSource — SSE API documentation