27.2. Server-Sent Events
Table of Contents
Understanding Server-Sent Events
Server-Sent Events (SSE) let a server push data to the browser over a single long‑lived HTTP connection. Unlike WebSockets, SSE is one‑way, from server to client, and is built directly on top of HTTP.
SSE is a good fit when the client only needs to receive updates, such as notifications, live logs, progress updates, or live scores.
Basic Idea of Server-Sent Events
With normal HTTP, the client makes a request, the server responds, and the connection closes. With SSE, the client makes one request and the server keeps that HTTP connection open. Whenever there is new data, the server sends a small text message over the same connection.
In the browser, you use the EventSource API to connect to an SSE endpoint:
const source = new EventSource('/events');
source.onmessage = (event) => {
console.log('New message:', event.data);
};
source.onerror = (err) => {
console.error('SSE error', err);
};On the backend, you expose an endpoint that:
- Uses HTTP GET.
- Returns content type
text/event-stream. - Never fully finishes, or finishes only when the stream ends.
- Sends data in a specific text format.
Key properties of SSE:
- Client connects using HTTP GET to an endpoint.
- Response header must include
Content-Type: text/event-stream. - Messages are sent as UTF‑8 encoded text lines that follow the SSE format.
- The HTTP connection is kept open as long as possible.
- The browser will automatically reconnect if the connection drops, unless disabled.
The SSE Message Format
SSE messages are just lines of text with special prefixes. The most common are:
data:for the message body.event:for a custom event name.id:for a message identifier.retry:to hint a reconnection delay in milliseconds.
An SSE stream is a sequence of messages separated by a blank line.
Minimal message
The simplest message has only a data: line:
data: Hello, world!The blank line at the end means "end of this message".
Multiline data
If the message body spans multiple lines, repeat data::
data: Line 1
data: Line 2
data: Line 3
The browser will join them with \n into one event.data string.
Custom event names
You can name events and listen to them separately:
event: notification
data: You have a new message
event: metrics
data: {"cpu": 0.42, "memory": 0.78}On the client:
const source = new EventSource('/events');
source.addEventListener('notification', (event) => {
console.log('Notification:', event.data);
});
source.addEventListener('metrics', (event) => {
const metrics = JSON.parse(event.data);
console.log('Metrics:', metrics);
});
If you omit event:, the browser treats it as a message event and calls onmessage.
Message IDs and retry
The id: line assigns an ID to a message, which browsers use for automatic reconnection. The retry: line tells the client how long to wait before reconnecting.
Example:
id: 1
retry: 5000
data: First message
id: 2
data: Second messageIf the connection drops, the browser will:
- Reconnect to the same URL.
- Send
Last-Event-ID: 2header (the lastidit saw). - Wait
retrymilliseconds (if set) before reconnecting.
You can use Last-Event-ID on the server to resume from where you stopped.
SSE vs WebSockets vs Polling
SSE is one tool among several for server to client updates.
| Feature | SSE | WebSockets | Long / Short Polling |
|---|---|---|---|
| Direction | Server → Client only | Bidirectional | Client → Server (pull) |
| Protocol | HTTP | Custom over TCP / HTTP upgrade | HTTP |
| Browser API | EventSource | WebSocket | fetch() / XMLHttpRequest |
| Message format | Text (UTF‑8) | Text or binary | Any HTTP response |
| Auto reconnection | Yes, built‑in | Manual | Not needed, new request each time |
| Works with proxies | Usually yes, plain HTTP | Sometimes tricky | Yes |
| Complexity | Low | Higher | Low to medium |
Use SSE when:
- You only need server to client updates.
- You prefer simple text messages (often JSON).
- You want to avoid the complexity of WebSockets.
If you need the client to send frequent real‑time messages to the server, WebSockets are usually a better fit.
HTTP Response Requirements
For an SSE endpoint, the HTTP response must be carefully set up.
Required headers
Basic headers:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-aliveDetails:
Content-Type: text/event-streamtells the browser to treat this as SSE.Cache-Control: no-cacheavoids proxies caching the stream.Connection: keep-alivehelps keep the TCP connection open.
In some setups, you may also add:
X-Accel-Buffering: nofor Nginx to disable buffering.Access-Control-Allow-Origin: ...if using CORS.
Streaming and flushing
The server must:
- Write messages incrementally.
- Flush them so the client receives them immediately.
- Not close the connection until the stream is finished or timed out.
In many frameworks, this means using streaming responses or generator functions.
Example: Simple SSE Endpoint (Python / FastAPI)
FastAPI does not have a special SSE type, but you can use StreamingResponse to send the event stream.
Backend example:
import asyncio
import json
from fastapi import FastAPI
from starlette.responses import StreamingResponse
app = FastAPI()
async def event_generator():
counter = 0
while True:
await asyncio.sleep(1)
counter += 1
data = {"counter": counter}
# Build SSE message
message = f"data: {json.dumps(data)}\n\n"
yield message
@app.get("/events")
async def events():
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
)Client example:
<!doctype html>
<html>
<body>
<div id="output"></div>
<script>
const output = document.getElementById('output');
const source = new EventSource('/events');
source.onmessage = (event) => {
const data = JSON.parse(event.data);
output.textContent = 'Counter: ' + data.counter;
};
source.onerror = (err) => {
console.error('SSE error', err);
};
</script>
</body>
</html>Every second, the client receives a new message and updates the page.
Custom Event Types Example
You can use event: to define different types of updates. For instance, suppose you have three kinds of events:
statusfor status changes.logfor log messages.progressfor progress updates.
Backend (simplified):
async def event_generator():
# status event
yield "event: status\ndata: started\n\n"
await asyncio.sleep(1)
# log event
yield "event: log\ndata: Step 1 completed\n\n"
await asyncio.sleep(1)
# progress event
yield "event: progress\ndata: 50\n\n"
await asyncio.sleep(1)
# final status
yield "event: status\ndata: finished\n\n"Client:
const source = new EventSource('/job-events');
source.addEventListener('status', (event) => {
console.log('Status:', event.data);
});
source.addEventListener('log', (event) => {
console.log('Log:', event.data);
});
source.addEventListener('progress', (event) => {
console.log('Progress:', event.data + '%');
});Handling Reconnection and Last-Event-ID
Browsers automatically reconnect on errors. You can use message IDs to continue from the last event.
Backend:
from fastapi import Request
async def event_generator(last_id: int):
current = last_id
while True:
await asyncio.sleep(1)
current += 1
data = {"counter": current}
message = f"id: {current}\ndata: {json.dumps(data)}\n\n"
yield message
@app.get("/events")
async def events(request: Request):
# Read Last-Event-ID, default to 0 if missing
last_id_header = request.headers.get("Last-Event-ID")
last_id = int(last_id_header) if last_id_header is not None else 0
generator = event_generator(last_id)
return StreamingResponse(generator, media_type="text/event-stream")
If the connection drops after message with id: 5, on reconnection the browser will send:
Last-Event-ID: 5Your server can use this to resume from ID 6.
Important SSE reconnection rules:
- Each message can have an
id:line. - The browser remembers the last
idit saw. - On reconnect, the browser sends
Last-Event-IDheader. - You can use
Last-Event-IDto avoid resending old events.
Common Use Cases
SSE is especially useful for:
Live notifications
For example, notifying a user when:
- A background task finishes.
- They receive a new message.
- A watched resource changes.
The client opens an EventSource when the web app loads and listens for notification events.
Live dashboards
For metrics and logs that need continuous updates:
- Server performance charts.
- Real‑time analytics.
- Application logs.
The server broadcasts new metrics to all connected SSE clients.
Progress updates for long tasks
Instead of polling a /status endpoint, the browser can:
- Kick off a long‑running job via a normal POST.
- Open an SSE connection to
/jobs/{id}/events. - Receive
progressandstatusevents until the job completes.
This is often simpler than maintaining WebSocket connections.
Practical Considerations and Limitations
Even though SSE is simple, you need to be aware of some details.
Connection limits
Browsers limit how many concurrent connections can be open to the same domain. This includes SSE connections. Often the limit is around 6 connections.
Avoid opening too many SSE streams per page. Usually, you want only one EventSource and then multiplex different event types over it.
Timeouts and proxies
Some proxies or load balancers close idle connections. Because SSE keeps connections open and can be relatively idle, you may need:
- Periodic heartbeat messages:
: heartbeat
Lines beginning with : are comments and are ignored by the browser, but keep the connection active.
- Proper configuration of idle timeouts on your reverse proxy (like Nginx or Traefik).
No binary data
SSE is text only. If you need to send binary data, you must encode it, for example with Base64, or use WebSockets instead. In practice, most SSE messages are JSON text, which works well.
CORS and authentication
SSE uses HTTP, so all normal HTTP concerns apply:
- CORS headers for cross‑origin access.
- Cookies, headers, or tokens for authentication.
- Security headers from your backend security configuration.
Many applications secure SSE endpoints with the same mechanisms as their normal APIs.
Example: Incremental Logs via SSE
Imagine you have a background process that produces logs as it runs. You can stream those logs to the client in real time.
Backend example structure:
import asyncio
from fastapi import FastAPI
from starlette.responses import StreamingResponse
app = FastAPI()
async def log_stream():
logs = [
"Starting job",
"Downloading data",
"Processing data",
"Saving results",
"Job complete"
]
for line in logs:
await asyncio.sleep(1)
yield f"event: log\ndata: {line}\n\n"
@app.get("/job/logs")
async def job_logs():
return StreamingResponse(log_stream(), media_type="text/event-stream")Client:
const source = new EventSource('/job/logs');
source.addEventListener('log', (event) => {
const el = document.createElement('div');
el.textContent = event.data;
document.body.appendChild(el);
});The user sees the logs appear line by line while the job is running.
When Not to Use SSE
SSE is not always the right choice. Avoid SSE when:
- You need full bidirectional real‑time communication between client and server. WebSockets are better for chat apps or collaborative editing.
- You need to send large amounts of binary data. Use WebSockets or plain HTTP downloads.
- Your environment has strict limitations on long‑lived HTTP connections and you cannot change proxy or server settings.
In many backend systems, a combination of technologies is common. For example:
- Use REST for normal CRUD.
- Use SSE for one‑way updates.
- Use WebSockets only where true bidirectional streaming is required.
Views: 8
KAHIBARO