Server-Sent Events (SSE)
How to implement one-way real-time streaming from server to browser using Server-Sent Events, with reconnection, event types, and multi-client broadcasting.
Overview
Server-Sent Events (SSE) is a browser API and HTTP-based protocol that enables servers to push real-time updates to clients over a single long-lived connection. Unlike WebSockets (full-duplex), SSE is uni-directional: server → client only. It runs over standard HTTP, works through most firewalls and proxies, has built-in auto-reconnection with Last-Event-ID, and requires no special protocol upgrades. This recipe covers implementing SSE endpoints in Python, JavaScript (Node.js), and Java (Spring Boot), with event types, heartbeat keepalives, and broadcasting to multiple clients.
When to Use
Use this resource when:
- You need real-time server-to-client updates (live scores, stock prices, notifications, logs)
- The data flow is primarily one-directional (server pushes, client only listens)
- You want automatic reconnection without writing custom WebSocket reconnection logic
- You need a simple solution that works through corporate firewalls and HTTP proxies
Solution
Python (Flask with Generator)
from flask import Flask, Response
import json
import time
app = Flask(__name__)
@app.route("/events")
def events():
def generate():
counter = 0
while True:
counter += 1
data = {"message": f"Update {counter}", "timestamp": time.time()}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(2)
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
# Named events with event types
@app.route("/notifications")
def notifications():
def generate():
yield "event: connected\ndata: \"Stream started\"\n\n"
for i in range(1, 10):
event_type = "alert" if i % 3 == 0 else "info"
data = {"level": event_type, "msg": f"Notification {i}"}
yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
time.sleep(2)
return Response(generate(), mimetype="text/event-stream")
# Broadcasting to multiple clients with a shared queue
from queue import Queue
import threading
clients = []
def broadcast(message):
for client in clients:
client.put(message)
@app.route("/broadcast")
def broadcast_stream():
q = Queue()
clients.append(q)
def generate():
try:
while True:
msg = q.get()
yield f"data: {json.dumps(msg)}\n\n"
finally:
clients.remove(q)
return Response(generate(), mimetype="text/event-stream")
JavaScript (Node.js with Express)
const express = require("express");
const app = express();
// Basic SSE endpoint
app.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable Nginx buffering
let counter = 0;
const interval = setInterval(() => {
counter++;
const data = JSON.stringify({ message: `Update ${counter}`, timestamp: Date.now() });
res.write(`data: ${data}\n\n`);
}, 2000);
// Cleanup on disconnect
req.on("close", () => {
clearInterval(interval);
console.log("Client disconnected");
});
});
// Broadcasting to all connected clients
const clients = new Set();
app.get("/broadcast", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
clients.add(res);
req.on("close", () => {
clients.delete(res);
});
});
function broadcastToAll(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => client.write(message));
}
// Named events
app.get("/notifications", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.write("event: connected\ndata: \"Stream started\"\n\n");
const types = ["info", "info", "alert", "info"];
let i = 0;
const interval = setInterval(() => {
if (i >= types.length) {
clearInterval(interval);
res.end();
return;
}
const eventType = types[i];
const data = JSON.stringify({ level: eventType, msg: `Notification ${i + 1}` });
res.write(`event: ${eventType}\ndata: ${data}\n\n`);
i++;
}, 2000);
req.on("close", () => clearInterval(interval));
});
Java (Spring Boot)
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@RestController
public class SseController {
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
SseEmitter emitter = new SseEmitter(0L); // No timeout
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError((e) -> emitters.remove(emitter));
// Send updates every 2 seconds
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event()
.data("{\"message\": \"Update\", \"timestamp\": " + System.currentTimeMillis() + "}"));
} catch (IOException e) {
emitters.remove(emitter);
}
}, 0, 2, TimeUnit.SECONDS);
return emitter;
}
@GetMapping(value = "/notifications", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamNotifications() {
SseEmitter emitter = new SseEmitter(0L);
try {
emitter.send(SseEmitter.event()
.name("connected")
.data("Stream started"));
String[] types = {"info", "info", "alert", "info"};
scheduler.scheduleAtFixedRate(new Runnable() {
int i = 0;
@Override
public void run() {
if (i >= types.length) {
emitter.complete();
return;
}
try {
emitter.send(SseEmitter.event()
.name(types[i])
.data("{\"level\": \"" + types[i] + "\", \"msg\": \"Notification " + (i + 1) + "\"}"));
i++;
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}, 2, 2, TimeUnit.SECONDS);
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
}
// Broadcast to all connected clients
public void broadcast(String message) {
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().data(message));
} catch (IOException e) {
emitters.remove(emitter);
}
}
}
}
Browser Client
// Basic connection
const eventSource = new EventSource("/events");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
eventSource.onerror = (error) => {
console.error("SSE error:", error);
};
// Named events
const notifications = new EventSource("/notifications");
notifications.addEventListener("connected", (e) => {
console.log("Connected:", e.data);
});
notifications.addEventListener("alert", (e) => {
const data = JSON.parse(e.data);
showAlert(data.msg); // Custom alert handler
});
notifications.addEventListener("info", (e) => {
const data = JSON.parse(e.data);
appendToLog(data.msg);
});
// Manual reconnection with custom ID
const source = new EventSource("/events");
source.addEventListener("update", (e) => {
const lastId = e.lastEventId; // Browser auto-sends this on reconnect
processUpdate(e.data, lastId);
});
Explanation
- Protocol — SSE uses standard HTTP with
Content-Type: text/event-stream. The server sends messages asfield: value\n\npairs. The browser’sEventSourceAPI handles connection lifecycle, auto-reconnection, and parsing automatically. - Message format — each message consists of fields:
data(payload),event(type name),id(for reconnection tracking), andretry(reconnection delay in ms). Multipledatalines are concatenated with newlines. - Auto-reconnection — if the connection drops, the browser waits (default 3 seconds, customizable via
retryfield) and reconnects automatically, sending the last receivedidasLast-Event-IDheader. The server can use this to resume from the correct point. - Broadcasting — maintain a registry of active response streams (or
SseEmitterobjects in Spring). When new data arrives, iterate over all clients and write the formatted SSE message. Always handle disconnections to prevent memory leaks.
Variants
| Approach | Transport | Direction | Best For |
|---|---|---|---|
| SSE | HTTP | Server → Client | Notifications, live feeds, progress bars |
| WebSocket | TCP upgrade | Bidirectional | Chat, gaming, collaborative editing |
| Long Polling | HTTP | Client → Server → Client | Legacy browser support, simple updates |
| Server-Sent Events with HTTP/2 | HTTP/2 | Server → Client | Multiplexed streams, lower overhead |
Best Practices
- Always set
X-Accel-Buffering: no— reverse proxies like Nginx buffer responses by default. This header disables buffering so SSE messages arrive immediately instead of being batched. - Use heartbeat keepalives — send periodic comment lines (
:heartbeat\n\n) every 15-30 seconds to prevent proxies and load balancers from closing idle connections. - Handle client disconnections — register
onCompletion,onTimeout, andonErrorcallbacks (orreq.on("close")in Node.js) to remove dead connections from your broadcast registry and prevent memory leaks. - Set appropriate
Cache-Control— useno-cacheto prevent browsers and proxies from caching the stream. SSE is inherently dynamic and caching breaks real-time delivery. - Use
eventtypes for routing — instead of putting event type inside the JSON payload, use the nativeevent: typenamefield. This lets the browser dispatch to specificaddEventListenerhandlers without parsing JSON first.
Common Mistakes
- Forgetting
X-Accel-Buffering: noorCache-Control: no-cache, causing Nginx or browsers to buffer SSE messages and deliver them in batches instead of real-time. - Not handling client disconnections, leading to memory leaks as dead connections accumulate in the broadcast registry.
- Sending SSE data without proper newlines (
\n\nterminator). The browser waits indefinitely for the message to complete. - Using SSE for bidirectional communication. SSE is uni-directional; for chat or two-way data, use WebSockets instead.
- Sending binary data directly. SSE only supports UTF-8 text. Base64-encode binary payloads or use WebSockets for binary streaming.
Frequently Asked Questions
How is SSE different from WebSockets?
SSE runs over standard HTTP (no protocol upgrade), is uni-directional (server → client only), has built-in auto-reconnection with Last-Event-ID, and works through most firewalls and proxies. WebSockets require a protocol upgrade, support bidirectional communication, but need custom reconnection logic. Use SSE for one-way streaming; use WebSockets for bidirectional real-time apps like chat or multiplayer games.
Can SSE work with HTTP/2?
Yes, and HTTP/2 significantly improves SSE by allowing multiple independent streams over a single TCP connection. In HTTP/1.1, browsers limit SSE connections to 6 per domain. HTTP/2 removes this limit, making SSE much more scalable for applications with multiple event streams.
How do I resume after a network interruption?
The browser automatically tracks the last received id field and sends it as the Last-Event-ID HTTP header on reconnection. Your server should read this header and resume streaming from that point. If no id was sent, the browser reconnects from the beginning of the stream.
Related Resources
API Versioning
How to version REST and GraphQL APIs to maintain backward compatibility while evolving your interface.
RecipeCall a REST API
How to make HTTP requests to a REST API and handle the JSON response in multiple languages.
RecipeHandle CORS Correctly
How to configure Cross-Origin Resource Sharing (CORS) headers correctly for APIs, SPAs, and serverless functions without opening security holes.
RecipeHandle Errors in APIs
Patterns for consistent, predictable API error handling across multiple languages and frameworks.
RecipeIdempotent API Endpoints
How to design and implement idempotent API endpoints that safely handle retries, duplicate requests, and network failures without side effects.