Intermediate
25 min
Full Guide
Server-Sent Events (SSE)
Implement one-way real-time communication from server to client
What are Server-Sent Events?
Server-Sent Events (SSE) is a standard for pushing real-time updates from server to client over HTTP. Unlike WebSocket's bidirectional communication, SSE is one-way: server → client only.
SSE is perfect for scenarios where the server needs to push updates but the client rarely or never needs to send data back—think live scores, news feeds, stock tickers, or notification systems.
📊 SSE vs WebSocket
| Feature | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client only | Bidirectional |
| Protocol | HTTP (works over HTTP/2) | WebSocket (ws://) |
| Data format | Text only (UTF-8) | Text + Binary |
| Reconnection | Built-in automatic | Manual implementation |
| Browser support | All modern (not IE) | All modern |
| Complexity | Very simple | More complex |
The EventSource API
// Client-side: Using EventSource
// Create connection to SSE endpoint
const eventSource = new EventSource('/api/events');
// Listen for generic messages (no event type)
eventSource.onmessage = (event) => {
console.log('Received:', event.data);
const data = JSON.parse(event.data);
updateUI(data);
};
// Listen for specific event types
eventSource.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data);
showNotification(notification);
});
eventSource.addEventListener('price-update', (event) => {
const update = JSON.parse(event.data);
updateStockPrice(update.symbol, update.price);
});
// Handle connection opened
eventSource.onopen = (event) => {
console.log('SSE connection established');
};
// Handle errors
eventSource.onerror = (event) => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Connection was closed');
} else if (eventSource.readyState === EventSource.CONNECTING) {
console.log('Reconnecting...');
}
};
// Check connection state
console.log(eventSource.readyState);
// 0 = CONNECTING
// 1 = OPEN
// 2 = CLOSED
// Close connection when done
eventSource.close();
SSE Server Implementation (Node.js)
const express = require('express');
const app = express();
// SSE endpoint
app.get('/api/events', (req, res) => {
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Disable buffering (important for nginx)
res.setHeader('X-Accel-Buffering', 'no');
// Send initial connection event
res.write('data: {"connected": true}\n\n');
// Send updates periodically
const intervalId = setInterval(() => {
const data = {
timestamp: new Date().toISOString(),
value: Math.random() * 100
};
// SSE format: "data: \n\n"
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
// Clean up on client disconnect
req.on('close', () => {
console.log('Client disconnected');
clearInterval(intervalId);
res.end();
});
});
app.listen(3000, () => {
console.log('SSE server running on http://localhost:3000');
});
SSE Message Format
// SSE messages follow a specific text format
// Basic message (triggers 'message' event)
data: Hello, World!
// Multi-line data (joined with newlines)
data: First line
data: Second line
data: Third line
// Named events (triggers specific event listeners)
event: notification
data: {"title": "New message", "body": "You have mail"}
event: price-update
data: {"symbol": "AAPL", "price": 150.25}
// Message ID (for reconnection tracking)
id: 12345
data: Message with ID
// Retry interval (milliseconds for reconnection)
retry: 5000
data: Reconnect after 5 seconds if disconnected
// Comments (ignored, but keep connection alive)
: this is a comment
// Complete example:
id: msg-001
event: user-joined
retry: 3000
data: {"userId": "123", "username": "john"}
// Note: Each message MUST end with double newline (\n\n)
Complete SSE Server with Event Types
const express = require('express');
const app = express();
// Store connected clients
const clients = new Set();
// SSE connection endpoint
app.get('/api/events', (req, res) => {
// SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// Add client to set
clients.add(res);
console.log(`Client connected. Total: ${clients.size}`);
// Send retry interval
res.write('retry: 3000\n\n');
// Send welcome event
sendEvent(res, 'connected', {
clientCount: clients.size,
serverTime: Date.now()
});
// Cleanup on disconnect
req.on('close', () => {
clients.delete(res);
console.log(`Client disconnected. Total: ${clients.size}`);
});
});
// Helper to send SSE events
function sendEvent(res, eventType, data, id = null) {
if (id) {
res.write(`id: ${id}\n`);
}
res.write(`event: ${eventType}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// Broadcast to all clients
function broadcast(eventType, data) {
const id = Date.now().toString();
clients.forEach(client => {
sendEvent(client, eventType, data, id);
});
}
// Example: Broadcast notifications
app.post('/api/notify', express.json(), (req, res) => {
const { title, message } = req.body;
broadcast('notification', {
title,
message,
timestamp: new Date().toISOString()
});
res.json({ sent: true, recipients: clients.size });
});
// Example: Simulate stock price updates
setInterval(() => {
const stocks = ['AAPL', 'GOOGL', 'MSFT', 'AMZN'];
const symbol = stocks[Math.floor(Math.random() * stocks.length)];
broadcast('stock-update', {
symbol,
price: (100 + Math.random() * 100).toFixed(2),
change: (Math.random() * 10 - 5).toFixed(2)
});
}, 2000);
app.listen(3000);
Handling Reconnection with Last-Event-ID
// Server: Track messages and support resume
const messageHistory = [];
const MAX_HISTORY = 100;
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Check if client is reconnecting
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
console.log('Client reconnecting from:', lastEventId);
// Send missed messages
const missedMessages = messageHistory.filter(
msg => msg.id > parseInt(lastEventId)
);
missedMessages.forEach(msg => {
res.write(`id: ${msg.id}\n`);
res.write(`event: ${msg.event}\n`);
res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
});
}
// ... continue with normal event stream
});
// Store messages with IDs
function broadcastWithHistory(eventType, data) {
const id = Date.now();
// Store in history
messageHistory.push({ id, event: eventType, data });
if (messageHistory.length > MAX_HISTORY) {
messageHistory.shift();
}
// Broadcast to clients
clients.forEach(client => {
sendEvent(client, eventType, data, id);
});
}
// Client: EventSource automatically sends Last-Event-ID on reconnect!
const eventSource = new EventSource('/api/events');
// The browser handles this automatically:
// 1. Connection drops
// 2. Browser waits (retry interval)
// 3. Browser reconnects with Last-Event-ID header
// 4. Server can send missed messages
SSE with Authentication
// Problem: EventSource doesn't support custom headers!
// Solution 1: Query parameter (not ideal, token in URL)
const eventSource = new EventSource(
`/api/events?token=${authToken}`
);
// Solution 2: Cookie-based auth (recommended)
// Set HttpOnly cookie on login, SSE endpoint reads it
app.get('/api/events', authMiddleware, (req, res) => {
// req.user is set by auth middleware reading cookie
const userId = req.user.id;
// ... SSE logic
});
// Solution 3: Use fetch + ReadableStream (modern approach)
async function connectSSE(url, token) {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream'
}
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// Parse SSE format manually
parseSSEMessages(text).forEach(handleMessage);
}
}
function parseSSEMessages(text) {
const messages = [];
const lines = text.split('\n');
let currentMessage = {};
for (const line of lines) {
if (line.startsWith('data: ')) {
currentMessage.data = line.slice(6);
} else if (line.startsWith('event: ')) {
currentMessage.event = line.slice(7);
} else if (line.startsWith('id: ')) {
currentMessage.id = line.slice(4);
} else if (line === '') {
if (currentMessage.data) {
messages.push(currentMessage);
currentMessage = {};
}
}
}
return messages;
}
Real-World Example: Live Feed
// Complete live news/social feed with SSE
// Server
const express = require('express');
const app = express();
const subscribers = new Map(); // userId -> response
app.get('/api/feed', authenticate, (req, res) => {
const userId = req.user.id;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
subscribers.set(userId, res);
// Send user's personalized feed
sendEvent(res, 'feed-init', {
userId,
timestamp: Date.now()
});
req.on('close', () => {
subscribers.delete(userId);
});
});
// When new content is posted
async function onNewPost(post) {
// Find followers who should see this
const followers = await getFollowers(post.authorId);
followers.forEach(followerId => {
const client = subscribers.get(followerId);
if (client) {
sendEvent(client, 'new-post', {
id: post.id,
author: post.author,
content: post.content,
timestamp: post.createdAt
});
}
});
}
// Client component (React example)
function LiveFeed() {
const [posts, setPosts] = useState([]);
useEffect(() => {
const eventSource = new EventSource('/api/feed', {
withCredentials: true // Send cookies
});
eventSource.addEventListener('new-post', (event) => {
const post = JSON.parse(event.data);
setPosts(prev => [post, ...prev]);
});
eventSource.addEventListener('post-updated', (event) => {
const update = JSON.parse(event.data);
setPosts(prev => prev.map(p =>
p.id === update.id ? { ...p, ...update } : p
));
});
eventSource.addEventListener('post-deleted', (event) => {
const { id } = JSON.parse(event.data);
setPosts(prev => prev.filter(p => p.id !== id));
});
return () => eventSource.close();
}, []);
return (
{posts.map(post => )}
);
}
⚠️ SSE Limitations
- ✗ One-way only - Use POST requests for client → server
- ✗ Text only - No binary data (use base64 if needed)
- ✗ Connection limits - Browsers limit ~6 HTTP connections per domain
- ✗ No custom headers - EventSource API doesn't support them
- ✗ HTTP/1.1 - Each SSE uses one connection; HTTP/2 multiplexes better
💡 When to Use SSE
- ✓ News feeds - One-way updates from server
- ✓ Notifications - Push alerts to users
- ✓ Live scores - Sports, elections, real-time data
- ✓ Stock tickers - Continuous price updates
- ✓ Progress updates - Long-running job status
- ✓ Simple real-time - When WebSocket is overkill