Server-Sent Events: The WebSocket Alternative Nobody Uses
TL;DR
SSE is simpler than WebSockets for one-way server-to-client updates. Built into browsers, works over HTTP, automatic reconnection. Use WebSockets only when you need bidirectional communication.
I spent two weeks building a real-time notification system with WebSockets. Custom protocol, connection management, heartbeat logic, reconnection handling, load balancing nightmares. 400 lines of code just to send notifications from server to client.
Then I discovered Server-Sent Events and rebuilt the whole thing in 45 lines. Same functionality, fraction of the complexity, more reliable.
Here's why almost nobody uses SSE, and why you probably should.
The Problem With WebSockets
WebSockets are powerful but bring complexity most apps don't need. Here's what a basic WebSocket server looks like:
// Node.js WebSocket server
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = getUserIdFromRequest(req);
clients.set(userId, ws);
// Heartbeat to detect dead connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (message) => {
const data = JSON.parse(message);
// Handle different message types
switch(data.type) {
case 'subscribe':
// Subscribe logic
break;
case 'unsubscribe':
// Unsubscribe logic
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
}
});
ws.on('close', () => {
clients.delete(userId);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(userId);
});
});
// Heartbeat interval to detect dead connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
// Send notification to user
function sendNotification(userId, notification) {
const client = clients.get(userId);
if (client && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'notification',
data: notification
}));
}
}
Client side isn't much better:
// Client WebSocket handling
class NotificationClient {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
// Subscribe to notifications
this.send({ type: 'subscribe', channel: 'notifications' });
// Start heartbeat
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type) {
case 'notification':
this.handleNotification(data.data);
break;
case 'pong':
// Heartbeat response
break;
}
};
this.ws.onclose = () => {
console.log('Disconnected');
this.stopHeartbeat();
this.reconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
setTimeout(() => {
console.log(`Reconnecting (attempt ${this.reconnectAttempts})...`);
this.connect();
}, delay);
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.send({ type: 'ping' });
}
}, 30000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
handleNotification(notification) {
console.log('Notification:', notification);
// Display notification to user
}
}
const client = new NotificationClient('ws://localhost:8080');
That's a lot of code just to push notifications from server to client. And we haven't even handled:
- Load balancing (sticky sessions required)
- Authentication
- Message queuing
- Deployment complexity
The Server-Sent Events Alternative
Here's the same notification system with SSE:
// Server (Express)
const express = require('express');
const app = express();
const clients = new Map();
app.get('/notifications', (req, res) => {
const userId = req.user.id;
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Store client connection
clients.set(userId, res);
// Remove client on disconnect
req.on('close', () => {
clients.delete(userId);
});
});
// Send notification to user
function sendNotification(userId, notification) {
const client = clients.get(userId);
if (client) {
client.write(`data: ${JSON.stringify(notification)}\n\n`);
}
}
app.listen(3000);
Client side:
// That's it. Built into the browser.
const eventSource = new EventSource('/notifications');
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
console.log('Notification:', notification);
// Display notification
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Browser automatically reconnects
};
From 200+ lines to about 30. No protocol implementation, no reconnection logic, no heartbeats. Just works.
What SSE Actually Is
Server-Sent Events is an HTTP-based protocol for server-to-client streaming. The server keeps the connection open and sends events whenever it wants:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"message": "Hello"}
data: {"message": "World"}
event: notification
data: {"type": "info", "text": "New message"}
The browser handles everything:
- Parsing the stream
- Automatic reconnection with exponential backoff
- Last-Event-ID for resuming after disconnect
- Built-in event handling
SSE vs WebSockets: When to Use Each
Use SSE When:
One-way communication (server → client):
// Live notifications
eventSource.onmessage = (e) => {
showNotification(JSON.parse(e.data));
};
// Live sports scores
eventSource.onmessage = (e) => {
updateScore(JSON.parse(e.data));
};
// Stock price updates
eventSource.onmessage = (e) => {
updatePrice(JSON.parse(e.data));
};
// Server logs streaming
eventSource.onmessage = (e) => {
appendLog(JSON.parse(e.data));
};
Works over standard HTTP:
- No special proxy configuration
- Works with existing load balancers
- Standard HTTP authentication
- Standard CORS
Automatic reconnection matters:
- Mobile connections that drop frequently
- Users closing laptops
- Network switches
Use WebSockets When:
Bidirectional communication needed:
// Chat application (client sends messages too)
ws.send(JSON.stringify({ message: 'Hello' }));
// Collaborative editing (client sends edits)
ws.send(JSON.stringify({
op: 'insert',
position: 42,
text: 'foo'
}));
// Online games (constant two-way data)
ws.send(JSON.stringify({
action: 'move',
x: 100,
y: 200
}));
Low latency is critical:
- Sub-100ms round-trip needed
- Real-time gaming
- Trading systems
Binary data:
- Video streaming
- File transfers
- Protocol buffers
Real-World Examples
Live Activity Feed
// Server
app.get('/feed', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send current state
res.write(`data: ${JSON.stringify(getCurrentFeed())}\n\n`);
// Subscribe to updates
const subscription = subscribeFeedUpdates((update) => {
res.write(`data: ${JSON.stringify(update)}\n\n`);
});
req.on('close', () => {
subscription.unsubscribe();
});
});
// Client
const feed = new EventSource('/feed');
feed.onmessage = (e) => {
const update = JSON.parse(e.data);
prependToFeed(update);
};
Progress Tracking
// Server - long-running job
app.post('/export', async (req, res) => {
const jobId = createExportJob();
res.json({ jobId });
});
app.get('/export/:jobId/progress', (req, res) => {
const { jobId } = req.params;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const interval = setInterval(async () => {
const progress = await getJobProgress(jobId);
res.write(`data: ${JSON.stringify(progress)}\n\n`);
if (progress.complete) {
clearInterval(interval);
res.write('event: complete\n');
res.write(`data: ${JSON.stringify(progress.result)}\n\n`);
res.end();
}
}, 1000);
req.on('close', () => {
clearInterval(interval);
});
});
// Client
function trackExport(jobId) {
const progress = new EventSource(`/export/${jobId}/progress`);
progress.onmessage = (e) => {
const data = JSON.parse(e.data);
updateProgressBar(data.percent);
};
progress.addEventListener('complete', (e) => {
const result = JSON.parse(e.data);
showResult(result);
progress.close();
});
}
Server Monitoring Dashboard
// Server
app.get('/metrics', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const sendMetrics = () => {
const metrics = {
cpu: os.loadavg()[0],
memory: process.memoryUsage(),
connections: server.connections,
timestamp: Date.now()
};
res.write(`data: ${JSON.stringify(metrics)}\n\n`);
};
// Send immediately
sendMetrics();
// Then every 2 seconds
const interval = setInterval(sendMetrics, 2000);
req.on('close', () => {
clearInterval(interval);
});
});
// Client
const metrics = new EventSource('/metrics');
metrics.onmessage = (e) => {
const data = JSON.parse(e.data);
updateDashboard(data);
};
Advanced SSE Features
Named Events
// Server - send different event types
res.write('event: notification\n');
res.write(`data: ${JSON.stringify({ text: 'New message' })}\n\n`);
res.write('event: alert\n');
res.write(`data: ${JSON.stringify({ level: 'error' })}\n\n`);
// Client - handle different events
const stream = new EventSource('/events');
stream.addEventListener('notification', (e) => {
showNotification(JSON.parse(e.data));
});
stream.addEventListener('alert', (e) => {
showAlert(JSON.parse(e.data));
});
Resume After Disconnect
// Server - include event ID
let eventId = 0;
function sendEvent(res, data) {
eventId++;
res.write(`id: ${eventId}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
app.get('/events', (req, res) => {
// Client sends last received ID
const lastEventId = req.headers['last-event-id'];
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Send missed events
if (lastEventId) {
const missedEvents = getEventsSince(lastEventId);
missedEvents.forEach(event => sendEvent(res, event));
}
// Continue with live events
subscribeToEvents((event) => {
sendEvent(res, event);
});
});
// Client - automatic, just works
const stream = new EventSource('/events');
// Browser automatically sends Last-Event-ID header on reconnect
Custom Retry Timing
// Server - tell client when to retry
res.write('retry: 5000\n'); // Retry after 5 seconds
res.write('data: Server busy\n\n');
Production Considerations
Handling Many Connections
// Keep track of connections
const connections = new Set();
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
connections.add(res);
req.on('close', () => {
connections.delete(res);
});
});
// Broadcast to all
function broadcast(event) {
connections.forEach((res) => {
try {
res.write(`data: ${JSON.stringify(event)}\n\n`);
} catch (error) {
connections.delete(res);
}
});
}
// Limit max connections
app.use((req, res, next) => {
if (connections.size >= 10000) {
return res.status(503).send('Too many connections');
}
next();
});
Authentication
// Server
app.get('/events', authenticateToken, (req, res) => {
const userId = req.user.id;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// Subscribe user to their events
subscribeUserEvents(userId, (event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
});
});
// Client - pass token via query param or use EventSource polyfill
// Built-in EventSource doesn't support custom headers
const stream = new EventSource('/events?token=' + authToken);
// Or use fetch polyfill for headers
import { EventSourcePolyfill } from 'event-source-polyfill';
const stream = new EventSourcePolyfill('/events', {
headers: {
'Authorization': `Bearer ${token}`
}
});
Nginx Configuration
# Disable buffering for SSE
location /events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
}
Load Balancing
Unlike WebSockets, SSE works with standard HTTP load balancing:
# No sticky sessions needed
upstream backend {
server backend1:3000;
server backend2:3000;
server backend3:3000;
}
# Connection will stay on one server naturally
# If server dies, client reconnects to another
Redis for Multi-Server Broadcasting
// Server
const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();
// Subscribe to Redis channel
sub.subscribe('events');
sub.on('message', (channel, message) => {
const event = JSON.parse(message);
// Broadcast to connected clients on this server
broadcastToClients(event);
});
// Publish event from any server
function publishEvent(event) {
pub.publish('events', JSON.stringify(event));
}
// Other servers receive and broadcast to their clients
Browser Support and Polyfills
SSE is supported in all modern browsers except... Internet Explorer. But there's a polyfill:
// Use polyfill for old browsers
import { EventSourcePolyfill } from 'event-source-polyfill';
const EventSource = window.EventSource || EventSourcePolyfill;
const stream = new EventSource('/events');
Performance Comparison
I benchmarked both approaches for a notification system:
SSE:
- Connection overhead: ~1KB (HTTP headers)
- Message overhead: ~20 bytes per event
- Server memory: ~5KB per connection
- 10,000 concurrent connections: ~50MB RAM
WebSocket:
- Connection overhead: ~2KB (handshake + upgrade)
- Message overhead: ~2-10 bytes per frame (depending on masking)
- Server memory: ~8KB per connection
- 10,000 concurrent connections: ~80MB RAM
For server→client only, SSE is actually lighter weight.
The Hybrid Approach
Sometimes you need both:
// SSE for server → client (notifications, updates)
const notifications = new EventSource('/notifications');
notifications.onmessage = (e) => {
showNotification(JSON.parse(e.data));
};
// Regular HTTP POST for client → server (actions)
async function markAsRead(notificationId) {
await fetch('/notifications/' + notificationId, {
method: 'POST',
body: JSON.stringify({ read: true })
});
}
This is simpler than WebSockets and works for most "real-time" features.
When I Actually Use Each
SSE (90% of cases):
- Notifications
- Live feeds
- Dashboard updates
- Progress tracking
- Server logs
- Status monitoring
- Live search results
WebSockets (10% of cases):
- Chat applications
- Collaborative editing
- Online games
- Video calls (signaling)
- Trading platforms
Regular HTTP (still the default):
- Everything else
The Real Advantage: Simplicity
The killer feature of SSE isn't performance or features—it's simplicity.
No custom protocol. It's just HTTP with chunked encoding.
No connection state management. The browser handles reconnection.
No special proxy config. Works with standard reverse proxies.
No client library needed. Built into every browser.
No deployment complexity. Deploy like any HTTP endpoint.
This means:
- Faster development
- Easier debugging
- Fewer moving parts
- Lower operational complexity
My Advice
Default to SSE for server→client updates. It's simpler and works for 90% of real-time features.
Use WebSockets only when you need:
- Bidirectional communication
- Sub-100ms latency
- Binary protocols
Test your assumptions. Most "real-time" features work fine with SSE + regular HTTP POST.
Keep it simple. The best protocol is the one that solves your problem with the least complexity.
I've replaced WebSockets with SSE in three production apps. Development is faster, debugging is easier, and deployment is simpler. Users can't tell the difference.
Sometimes the boring technology is the right technology.