Server-Sent Events: One-Way Real-Time Updates
Server-Sent Events (SSE) is the underappreciated workhorse of real-time web communications. While WebSockets grab headlines for their bidirectional capabilities, SSE quietly powers countless...
Key Insights
- Server-Sent Events provide a lightweight, HTTP-based protocol for one-way server-to-client streaming with automatic reconnection, making them ideal for live feeds, notifications, and progress updates where bidirectional communication isn’t needed.
- SSE uses standard HTTP connections with the
text/event-streamMIME type, offering simpler implementation than WebSockets while providing built-in features like automatic reconnection and event IDs for resuming interrupted streams. - The browser’s EventSource API handles connection management automatically, but proper server-side implementation requires careful attention to headers, connection tracking, and memory management to handle multiple concurrent clients efficiently.
Introduction to Server-Sent Events
Server-Sent Events (SSE) is the underappreciated workhorse of real-time web communications. While WebSockets grab headlines for their bidirectional capabilities, SSE quietly powers countless dashboards, notification systems, and live feeds with a fraction of the complexity.
The key distinction: SSE is unidirectional. The server pushes data to the client, and that’s it. No client-to-server messaging over the same connection. This limitation is actually SSE’s strength—it uses standard HTTP, works through most proxies and firewalls without special configuration, and provides automatic reconnection out of the box.
Choose SSE when you need server-to-client updates and don’t require bidirectional communication. Perfect use cases include live sports scores, stock tickers, server monitoring dashboards, notification feeds, and progress indicators for long-running operations. If you need the client to send data back frequently, WebSockets or regular HTTP requests are better choices.
How SSE Works Under the Hood
SSE leverages HTTP’s chunked transfer encoding to keep a connection open indefinitely. The server sets the Content-Type header to text/event-stream and sends data in a specific text format. The browser’s EventSource API manages the connection, automatically reconnecting if it drops.
The connection lifecycle is straightforward: the client initiates a GET request, the server responds with appropriate headers and keeps the connection open, then streams events as they occur. If the connection breaks, EventSource automatically attempts to reconnect after a delay (default 3 seconds).
Here’s the basic client-side setup:
const eventSource = new EventSource('/api/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
eventSource.onerror = (error) => {
console.error('EventSource error:', error);
};
// Clean up when done
// eventSource.close();
The EventSource API is elegantly simple. You provide a URL, attach event listeners, and it handles the rest. The onmessage handler receives all unnamed events, while onerror fires on connection issues.
Building a Simple SSE Server
Server implementation requires setting specific headers and formatting messages correctly. The critical headers are Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive. Some proxies also require X-Accel-Buffering: no to prevent buffering.
The SSE message format is line-based. Each message consists of one or more fields, with each field on its own line starting with a field name followed by a colon. The most common fields are data: (the message content), event: (event type), id: (message ID for reconnection), and retry: (reconnection time in milliseconds).
Here’s a practical Node.js/Express implementation that streams server metrics:
const express = require('express');
const app = express();
app.get('/api/metrics', (req, res) => {
// Set 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');
// Send initial connection success
res.write('data: {"status":"connected"}\n\n');
// Send metrics every 2 seconds
const intervalId = setInterval(() => {
const metrics = {
cpu: Math.random() * 100,
memory: Math.random() * 16,
timestamp: Date.now()
};
res.write(`data: ${JSON.stringify(metrics)}\n\n`);
}, 2000);
// Clean up on client disconnect
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
});
app.listen(3000, () => {
console.log('SSE server running on port 3000');
});
Notice the \n\n double newline—that’s required to terminate each message. The req.on('close') handler is crucial for cleaning up resources when clients disconnect.
Advanced SSE Patterns
Custom event types let you route different message types to specific handlers. The server specifies the event type, and the client listens for it by name. Message IDs enable connection resumption—if a client reconnects, it sends the last received ID via the Last-Event-ID header, allowing the server to replay missed events.
Here’s a server sending different event types:
app.get('/api/trading', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let messageId = 0;
const sendPriceUpdate = () => {
messageId++;
const data = {
symbol: 'AAPL',
price: 150 + Math.random() * 10
};
res.write(`id: ${messageId}\n`);
res.write(`event: price-update\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const sendAlert = () => {
messageId++;
res.write(`id: ${messageId}\n`);
res.write(`event: alert\n`);
res.write(`data: {"message":"Price threshold exceeded!"}\n\n`);
};
const priceInterval = setInterval(sendPriceUpdate, 1000);
// Send alert randomly
const alertInterval = setInterval(() => {
if (Math.random() > 0.8) sendAlert();
}, 5000);
req.on('close', () => {
clearInterval(priceInterval);
clearInterval(alertInterval);
res.end();
});
});
Client-side handling for custom events:
const eventSource = new EventSource('/api/trading');
eventSource.addEventListener('price-update', (event) => {
const data = JSON.parse(event.data);
updatePriceDisplay(data.symbol, data.price);
});
eventSource.addEventListener('alert', (event) => {
const data = JSON.parse(event.data);
showNotification(data.message);
});
Error Handling and Connection Management
Robust SSE implementations need proper error handling and connection state management. The EventSource API provides three ready states: CONNECTING (0), OPEN (1), and CLOSED (2). Monitor these states to provide user feedback.
Here’s a React component with comprehensive connection management:
import { useEffect, useRef, useState } from 'react';
function LiveFeed() {
const [status, setStatus] = useState('disconnected');
const [messages, setMessages] = useState([]);
const eventSourceRef = useRef(null);
useEffect(() => {
const connectToStream = () => {
const es = new EventSource('/api/events');
eventSourceRef.current = es;
es.onopen = () => {
setStatus('connected');
console.log('SSE connection established');
};
es.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages(prev => [data, ...prev].slice(0, 50)); // Keep last 50
};
es.onerror = (error) => {
console.error('SSE error:', error);
setStatus('error');
// EventSource automatically reconnects, but we can monitor
if (es.readyState === EventSource.CLOSED) {
setStatus('disconnected');
} else {
setStatus('reconnecting');
}
};
};
connectToStream();
// Cleanup on unmount
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, []);
return (
<div>
<div>Status: {status}</div>
<ul>
{messages.map((msg, idx) => (
<li key={idx}>{JSON.stringify(msg)}</li>
))}
</ul>
</div>
);
}
Server-side connection tracking prevents memory leaks with many concurrent connections:
const clients = new Set();
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
clients.add(res);
console.log(`Client connected. Total clients: ${clients.size}`);
req.on('close', () => {
clients.delete(res);
console.log(`Client disconnected. Total clients: ${clients.size}`);
});
});
// Broadcast to all connected clients
function broadcast(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => {
client.write(message);
});
}
Real-World Use Cases and Performance
SSE excels in scenarios where you’re pushing updates to many clients simultaneously. Live dashboards, activity feeds, and real-time notifications are ideal applications. Performance-wise, SSE is lightweight—each connection is just an open HTTP request, consuming minimal server resources compared to polling.
Scaling considerations: each connection holds a server thread or event loop slot. For thousands of concurrent connections, use Node.js (event-driven) or Go (lightweight goroutines) rather than traditional threaded servers. Consider Redis or a message queue to distribute events across multiple server instances.
Here’s a complete activity feed implementation:
// Server
const activities = [];
app.get('/api/activity-feed', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const filter = req.query.type; // Optional filtering
clients.add({ res, filter });
// Send recent activities on connect
activities.slice(-10).forEach(activity => {
if (!filter || activity.type === filter) {
res.write(`data: ${JSON.stringify(activity)}\n\n`);
}
});
req.on('close', () => {
clients.delete(res);
});
});
// Endpoint to create new activity
app.post('/api/activity', express.json(), (req, res) => {
const activity = {
id: Date.now(),
type: req.body.type,
message: req.body.message,
timestamp: new Date().toISOString()
};
activities.push(activity);
// Broadcast to matching clients
clients.forEach(({ res: clientRes, filter }) => {
if (!filter || activity.type === filter) {
clientRes.write(`data: ${JSON.stringify(activity)}\n\n`);
}
});
res.json({ success: true });
});
Browser Support and Polyfills
SSE is supported in all modern browsers except Internet Explorer. Edge (Chromium), Chrome, Firefox, and Safari all have solid implementations. The main limitation: EventSource doesn’t support custom headers, making authentication tricky. Common workarounds include passing tokens as query parameters or using cookies.
For older browsers, use the event-source-polyfill package, which provides EventSource functionality via XHR polling. However, if you need to support IE11, consider whether the complexity is worth it—you might be better served with a polling solution or WebSockets with a fallback.
SSE is a pragmatic choice for one-way real-time updates. It’s simpler than WebSockets, more efficient than polling, and works reliably across infrastructure. For live feeds, notifications, and streaming updates, SSE delivers real-time functionality without the complexity overhead.