Introduction to Server-Sent Events
Server-Sent Events (SSE) is a standard web technology that allows servers to push real-time updates to clients over a single, long-lived HTTP connection. Unlike the traditional request–response cycle where the client always initiates communication, SSE flips the model: the server retains an open channel and streams data as soon as it becomes available. The client simply opens a connection and listens, receiving messages as they arrive.
SSE is built entirely on standard HTTP and uses the text/event-stream MIME type. It’s natively supported in all modern browsers through the EventSource API, making it incredibly easy to implement without any third-party libraries. Whether you’re delivering live stock tickers, news feeds, server metrics, or social media updates, SSE offers a lightweight, reliable, and auto-reconnecting solution for unidirectional data flow from server to client.
Why SSE Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before SSE, developers typically resorted to polling or heavyweight WebSocket connections for real-time data. Each approach has its drawbacks:
- Short polling — the client repeatedly fetches data at fixed intervals, wasting resources on empty responses and creating latency.
- Long polling — the server holds a request open until an event occurs, then the client immediately reopens it. It reduces waste but still carries overhead from constant reconnects and headers.
- WebSockets — full-duplex, bidirectional communication. Perfect for chat apps or collaborative editing, but often overkill for simple server-to-client notifications. WebSockets require an upgrade handshake, special server support, and can complicate proxy configurations.
SSE shines when you only need the server to push updates to clients. It’s simpler than WebSockets, works transparently with HTTP/1.1 and HTTP/2, benefits from existing HTTP infrastructure (load balancers, CDNs, authentication), and includes built-in browser features like automatic reconnection and event ID tracking. If your use case is live dashboards, status changes, log streaming, or notification feeds, SSE is often the ideal choice.
How Server-Sent Events Work
The protocol is straightforward. The client initiates an HTTP GET request with Accept: text/event-stream. The server responds with headers:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
The connection remains open, and the server writes a stream of UTF-8 encoded event messages. Each event is a block of text fields separated by newlines. The fields include:
- event: — an optional event type (e.g.,
price_update,alert). If omitted, the client fires the genericonmessagehandler. - data: — the payload, which can span multiple lines. A single event block ends with a blank line.
- id: — a unique identifier for the event. The browser uses it to set the
Last-Event-IDheader when reconnecting, allowing the server to resume the stream from where the client left off. - retry: — a custom reconnection time in milliseconds that overrides the default (usually a few seconds).
Comments (lines starting with a colon) are used as keep-alive pings to prevent the connection from being closed by proxies or timeouts. The client-side EventSource interface handles parsing, events, and reconnection automatically.
Building a Node.js SSE Server
Let’s start with a minimal Express server. The key is to set the proper headers and then write events in a loop, keeping the response open indefinitely.
const express = require('express');
const app = express();
app.get('/events', (req, res) => {
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*', // adjust for production
});
// Prevent proxy/timeout from closing the connection
res.socket.setTimeout(0);
let eventId = 0;
// Send an initial comment to establish keep-alive
res.write(':ok\n\n');
// Send events every 2 seconds
const intervalId = setInterval(() => {
eventId++;
const data = JSON.stringify({
time: new Date().toISOString(),
value: Math.random() * 100,
});
// Send event with ID and named event type
res.write(`id: ${eventId}\n`);
res.write(`event: metric_update\n`);
res.write(`data: ${data}\n\n`);
}, 2000);
// Cleanup on client disconnect
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
});
app.listen(3000, () => console.log('SSE server on port 3000'));
Notice the res.socket.setTimeout(0) call – it disables Node.js’s default socket timeout, which would otherwise kill the long-lived connection. The comment :ok\n\n serves as an initial keep-alive. Each event block ends with an extra newline, completing the event boundary. When the client disconnects, the 'close' event triggers cleanup to avoid dangling timers.
Client-Side Implementation with EventSource
On the browser, consuming an SSE stream is as simple as instantiating an EventSource with the server URL.
const eventSource = new EventSource('http://localhost:3000/events');
// Listen for the 'metric_update' event we defined on the server
eventSource.addEventListener('metric_update', (event) => {
const payload = JSON.parse(event.data);
console.log('Received metric:', payload);
// Update UI with payload.value and payload.time
});
// Generic message handler (fires for events without a type)
eventSource.onmessage = (event) => {
console.log('Generic message:', event.data);
};
// Error handling and reconnection
eventSource.onerror = (err) => {
console.error('EventSource error:', err);
// The browser will automatically reconnect using Last-Event-ID
};
The EventSource API automatically handles reconnection. If the connection drops, the browser waits the specified retry duration (or a default) and reconnects, sending the Last-Event-ID header so the server can resume. You don't need to write any reconnection logic yourself. The readyState property can be checked: EventSource.CONNECTING, OPEN, or CLOSED.
Python Flask SSE Server
Flask is equally well-suited for SSE. The key is returning a generator-based response with the text/event-stream mimetype.
from flask import Flask, Response, stream_with_context
import time
import json
app = Flask(__name__)
@app.route('/events')
def sse_endpoint():
def generate():
event_id = 0
while True:
event_id += 1
data = json.dumps({
'time': time.strftime('%Y-%m-%d %H:%M:%S'),
'value': 42 + event_id % 10
})
yield f"id: {event_id}\n"
yield f"event: metric_update\n"
yield f"data: {data}\n\n"
time.sleep(2)
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
}
)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, threaded=True)
Using stream_with_context ensures the generator runs within the request context, which is crucial if you need access to Flask globals like request or session data. The infinite loop writes events every two seconds; the client’s disconnect will eventually stop the generator (Flask detects the broken pipe and stops iteration).
Handling Multiple Clients and Broadcasting
In a real application, you rarely want a single client stream. You need to broadcast events to all connected clients. The typical pattern is to maintain a list of active response objects (or equivalent queue) and push data to each one.
Node.js Broadcast Example
const clients = new Set();
app.get('/events', (req, res) => {
res.writeHead(200, { /* headers as before */ });
res.socket.setTimeout(0);
clients.add(res);
req.on('close', () => {
clients.delete(res);
});
});
// Somewhere in your application, when an event occurs:
function broadcast(eventType, data) {
const payload = JSON.stringify(data);
for (const client of clients) {
client.write(`event: ${eventType}\n`);
client.write(`data: ${payload}\n\n`);
}
}
Python Flask Broadcast
import queue
clients = []
@app.route('/events')
def sse():
q = queue.Queue()
clients.append(q)
def generate():
while True:
try:
event = q.get(timeout=30) # 30s keep-alive
yield event
except queue.Empty:
yield ': heartbeat\n\n'
return Response(stream_with_context(generate()), mimetype='text/event-stream')
@app.route('/publish', methods=['POST'])
def publish():
data = request.json
payload = f"data: {json.dumps(data)}\n\n"
for client_queue in clients:
client_queue.put(payload)
return '', 204
When scaling to multiple processes or servers, you'll need a message bus like Redis Pub/Sub or a dedicated message broker so all instances can receive broadcast events and push them to their local clients. Sticky sessions can help, but a pub/sub backend is more robust.
Best Practices
- Keep-alive pings — send a comment line (
: ping\n\n) every 15–30 seconds. This prevents intermediate proxies (Nginx, AWS ALB) from closing the idle connection. - Set appropriate headers — always include
Cache-Control: no-cache(notno-store) andConnection: keep-alive. For cross-origin requests, setAccess-Control-Allow-Originto the exact origin, not*, if cookies are involved. - Use event IDs and
retry— assign a unique ID to each event so the client can sendLast-Event-IDon reconnection. Include aretryfield to control the reconnection delay (e.g.,retry: 3000). - Avoid response buffering — disable Nagle’s algorithm and any reverse-proxy buffering. In Node.js, you can use
res.flushHeaders()to send headers immediately. For Nginx, addproxy_buffering off;andX-Accel-Buffering: noheader. - Handle connection limits — browsers limit the number of concurrent
EventSourceconnections to a single domain (usually 6). Use HTTP/2 multiplexing to overcome this, or use a single SSE channel that carries multiplexed events by type. - Graceful shutdown — on server restart, send a final event with a custom type like
server_shutdownso clients can handle it, then close connections cleanly. - Compression — compression can introduce buffering. If you need it, ensure you flush compressed data periodically. For high-frequency updates, consider sending uncompressed JSON with keep-alive comments to minimize latency.
- Error handling on the client — use
eventSource.onerrorto detect failures, but remember the browser will retry automatically. If you want to stop reconnection, calleventSource.close()inside the error handler after a certain number of retries.
Security Considerations
SSE endpoints should be protected like any other HTTP endpoint. Because EventSource does not support custom headers (you cannot set Authorization), authentication is often done via cookies (same-origin) or a token passed as a query parameter. If using a query parameter, ensure it is sent over HTTPS to avoid exposure. Validate the Origin header on the server to prevent cross-origin abuse when cookies are used. For sensitive streams, consider requiring a short-lived ticket exchanged from a regular authenticated endpoint before opening the SSE connection.
Also be mindful of the potential for open connections to exhaust server resources. Set reasonable maximum connection limits per client IP, monitor file descriptor usage, and implement back-pressure detection on the stream to avoid overwhelming a slow client.
Conclusion
Server-Sent Events offer a beautifully simple yet powerful mechanism for real-time, server-to-client communication. They leverage ubiquitous HTTP infrastructure, come with built-in browser reconnection and event ID tracking, and require minimal server-side logic. Whether you’re building a live dashboard, a notification feed, or a log viewer, SSE provides a reliable and efficient alternative to polling and WebSockets for unidirectional data flows. By following the patterns and best practices outlined here—proper headers, keep-alive pings, event IDs, and thoughtful client handling—you can deliver robust real-time experiences with remarkably little code. Give SSE a try in your next project; you might find it’s exactly the lightweight streaming solution you’ve been looking for.