What is Server-Sent Events (SSE)?
Server-Sent Events (SSE) is a standardized technology that allows a server to push real-time updates to a client over a single, long-lived HTTP connection. Unlike WebSockets, which enable bidirectional communication, SSE is strictly unidirectional β data flows only from server to client. The client opens a connection using the browserβs built-in EventSource API, and the server keeps the response open, streaming specially formatted text as events arrive.
The protocol rides on top of standard HTTP (or HTTP/2) and uses the text/event-stream MIME type. Because it is simple HTTP, SSE works effortlessly through proxies, load balancers, and existing infrastructure that already understands HTTP semantics. The event stream format is lightweight: each message consists of one or more data: lines, an optional event: line to name the event type, and an optional id: line for resuming. Messages are separated by a blank line.
Why SSE Matters
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →SSE shines in scenarios where the server needs to continuously push data to the client, but the client rarely needs to send data back (beyond the initial request). This covers a wide range of practical use cases:
- Real-time notifications β social feeds, alert banners, system status updates.
- Live dashboards β stock tickers, server metrics, IoT sensor readings.
- Progress reporting β file upload processing, long-running job completion percentages.
- Log streaming β tailing server logs directly into a browser window.
- Collaborative features β broadcasting document changes to other viewers without requiring a full duplex channel.
Compared to alternatives like polling (wasteful, high latency) or WebSockets (complex upgrade handshake, bidirectional overhead), SSE offers a sweet spot: minimal client code, automatic reconnection, built-in event IDs for resuming, and zero dependency on external libraries. It leverages the same HTTP/2 multiplexing benefits, allowing many concurrent streams over a single connection. For server-to-client streaming, SSE is often the most pragmatic choice.
How SSE Works: The Protocol
An SSE transaction starts with a standard HTTP GET request. The client must include the Accept: text/event-stream header to signal its capability. The server responds with HTTP status 200, sets the Content-Type to text/event-stream, disables caching via Cache-Control: no-cache, and keeps the connection open using Connection: keep-alive (or implicitly via HTTP/2).
Once the connection is established, the server writes event stream messages. Each message can contain the following fields, defined by the specification:
event:β An arbitrary string naming the event type. If omitted, the client fires the genericonmessagehandler.data:β The payload. Can span multiple lines if each starts withdata:. The client concatenates them.id:β A unique identifier for the event. The client stores this and sends it back via theLast-Event-IDheader on reconnection, enabling resuming.retry:β A timeout (in milliseconds) for the client to wait before reconnecting if the connection drops.
Lines beginning with a colon (:) are comments and are ignored by clients, often used as keep-alive pings to prevent intermediate proxies from closing the connection. A blank line signals the end of a message. Here is an example raw stream:
: this is a comment
event: stock-update
data: {"symbol":"AAPL", "price": 174.50}
id: 42
data: initial heartbeat
event: alert
data: {"severity":"high", "message":"CPU over 90%"}
id: 43
retry: 3000
Implementing SSE: Server Side (Node.js with Express)
Let's build a minimal SSE server using Express. The key is to keep the response writable and never call res.end() until the client disconnects. We disable response compression because compressed streams cannot be flushed incrementally; each chunk must be sent immediately.
const express = require('express');
const app = express();
// Store active SSE connections
const clients = new Set();
app.get('/events', (req, res) => {
// Set headers for SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// Optional: disable compression middleware if present
'X-Accel-Buffering': 'no', // for Nginx
});
// Send initial comment to open the stream
res.write(':ok\n\n');
// Add client to set
const clientId = Date.now();
clients.add(res);
console.log(`Client connected (${clients.size} total)`);
// Send a welcome event
res.write(`event: connected\ndata: {"clientId":${clientId}}\n\n`);
// Clean up on client disconnect
req.on('close', () => {
clients.delete(res);
console.log(`Client disconnected (${clients.size} remaining)`);
});
});
// Example: broadcast to all clients every 5 seconds
setInterval(() => {
const timestamp = new Date().toISOString();
const message = `data: ${JSON.stringify({ time: timestamp })}\n\n`;
for (const client of clients) {
client.write(message);
}
}, 5000);
// Keep-alive pings every 15 seconds to prevent proxy timeouts
setInterval(() => {
for (const client of clients) {
client.write(':ping\n\n');
}
}, 15000);
app.listen(3000, () => {
console.log('SSE server running on http://localhost:3000');
});
Notice how we store each response object (res) in a Set. This allows broadcasting to all connected clients. The req.on('close') handler ensures we remove disconnected clients to prevent memory leaks. The keep-alive ping (:ping) is a comment line that keeps the connection alive through proxies and load balancers without triggering client events.
Handling Custom Events and IDs
To send a named event with an ID for resuming, we simply prepend the appropriate fields:
function sendEvent(res, eventName, data, id) {
const lines = [];
if (eventName) lines.push(`event: ${eventName}`);
lines.push(`data: ${JSON.stringify(data)}`);
if (id !== undefined) lines.push(`id: ${id}`);
lines.push(''); // blank line terminates message
res.write(lines.join('\n') + '\n');
}
Resuming from Last Event ID (Server-side)
When a client reconnects, it sends the Last-Event-ID HTTP header. We can read it from req.headers['last-event-id'] and replay missed events (assuming we stored them in a database or queue). For simplicity, we can just acknowledge the ID and continue streaming. The following snippet shows a basic approach:
app.get('/events', (req, res) => {
const lastId = req.headers['last-event-id'] || '0';
console.log(`Client reconnected, last event ID: ${lastId}`);
// In a real app, fetch missed events from a store and send them
// For now, just set up streaming as before...
res.writeHead(200, { /* headers */ });
res.write(`event: resumed\ndata: {"lastId":"${lastId}"}\n\n`);
// ... continue streaming
});
Implementing SSE: Client Side (JavaScript)
On the client, the EventSource interface handles everything. Instantiate it with the server URL, and attach listeners. The browser automatically parses the stream, fires events, and reconnects on failure with exponential backoff (or using the retry field).
const eventSource = new EventSource('http://localhost:3000/events');
// Generic message handler (no event field)
eventSource.onmessage = (e) => {
console.log('Generic message:', e.data);
};
// Named event handlers
eventSource.addEventListener('stock-update', (e) => {
const stock = JSON.parse(e.data);
console.log(`Stock update: ${stock.symbol} $${stock.price}`);
});
eventSource.addEventListener('alert', (e) => {
const alert = JSON.parse(e.data);
console.warn(`Alert (${alert.severity}): ${alert.message}`);
});
// Connection opened
eventSource.onopen = () => {
console.log('SSE connection established');
};
// Error handling (including reconnection attempts)
eventSource.onerror = (e) => {
// The browser will attempt to reconnect automatically
console.error('SSE error, readyState:', eventSource.readyState);
// readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
// If the server set a retry field, the browser uses it; otherwise backoff.
};
// To manually close the connection (e.g., user navigates away)
// eventSource.close();
The EventSource API automatically sends the Accept: text/event-stream header and handles Last-Event-ID on reconnect. It also respects the retry field from the server, overriding the default backoff algorithm. When the connection drops, it transitions to CONNECTING state and retries indefinitely β you rarely need to manage reconnection yourself.
Best Practices
-
Use Event IDs and the
retryfield. Assign a monotonically increasing ID to each event. This enables clients to resume without losing data. Set a reasonableretryvalue (e.g., 3000ms) to control reconnection pacing. -
Send keep-alive comments. Every 15β30 seconds, write a comment line (
: ping) to prevent proxies and load balancers (like Nginx or AWS ALB) from terminating the idle connection. Some environments require data within a certain idle timeout window. -
Disable response buffering. In Node.js, if you use compression middleware, exclude SSE routes. For Nginx, set
proxy_buffering off;and addX-Accel-Buffering: noheader. This ensures events reach the client immediately. -
Handle CORS correctly. SSE requests are simple GETs, but if your frontend is on a different origin, you need the server to respond with
Access-Control-Allow-Originand possiblyAccess-Control-Allow-Credentials. TheEventSourceAPI does not supportwithCredentialson the constructor; credentials are handled via cookies on the same origin or by usingwithCredentialson an XMLHttpRequest fallback (rare). - Limit concurrent connections. SSE ties up a TCP socket (or HTTP/2 stream). Use a connection pool on the server and enforce a maximum number of clients. Consider authenticating requests via a token in the query string or cookie before establishing the stream.
-
Graceful degradation for older browsers. While
EventSourceis widely supported, you can polyfill with a simple AJAX long-polling fallback, or use libraries that abstract the difference. - Prefer HTTP/2. HTTP/2 multiplexing lets many SSE streams share a single connection, avoiding the per-connection overhead of HTTP/1.1. Most modern servers and CDNs support it out of the box.
- Monitor and log. Track active connections, event throughput, and error rates. Abrupt client disconnections are normal (network issues, tab close); log them without alarm, but watch for patterns indicating resource exhaustion.
Conclusion
Server-Sent Events provide a remarkably straightforward, standards-based mechanism for real-time server-to-client streaming. By sticking to plain HTTP and a minimal text format, SSE eliminates the complexity of bidirectional protocols while delivering automatic reconnection, event resuming, and trivial client implementation. For any application where data predominantly flows from server to client β dashboards, notifications, log feeds, progress updates β SSE should be your first consideration. Following the patterns above, you can build a robust, scalable SSE infrastructure that coexists seamlessly with your existing HTTP stack and delivers instantaneous updates with minimal overhead.